Compare commits

...
Author SHA1 Message Date
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
bflatasticandClaude Opus 5.5 a52ad5cac8 Fix #703: the Templates folder is in the desktop's documents folder
New from Template... and Save as Template opened their dialog in
~/Documents/VectorCraft Templates. On desktops whose documents folder has
another name (Italian ~/Documenti) or where the folder was never made, the
dialog started in a folder that doesn't exist and showed an error.

- The default Templates folder is now in the user's documents folder: on Linux
  and the BSDs the one XDG_DOCUMENTS_DIR names in user-dirs.dirs, elsewhere
  ~/Documents.
- The folder a dialog starts in is made when missing; where it can't be, the
  dialog starts in the nearest folder above it that exists, else home.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:22:32 +07:00
BFlat acd5dec214 Merge pull request #705 from storytold/fix/701-rotate-cursor
Fix #701: the rotate cursor is a curved double arrow
2026-10-09 14:17:26 +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
bflatasticandClaude Opus 5.5 aef5790e01 Fix #701: the rotate cursor is a curved double arrow
The rotate cursor drew an arc with a large triangle under each end, both
pointing straight down and away from the arc, so it didn't read as an arrow.
Each end now has a slim arrowhead pointing on along the arc's tangent, with
the same halo as the other cursors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:13:03 +07:00
BFlat f59be80671 Merge pull request #702 from storytold/pr/land-698-699
Land #698, #699: a saved multi-artboard .ai reopens as its document; FreeBSD hexagon test
2026-10-09 14:05:00 +07:00
bflatastic b411a3d158 Merge #699 2026-10-09 13:57:37 +07:00
bflatastic a27c912f8a Merge #698 2026-10-09 13:57:37 +07:00
BFlat 5d74c5bb77 Merge pull request #700 from storytold/fix/696-field-widths
Fix #696: the Properties panel's number fields are all one width
2026-10-09 13:57:14 +07:00
bflatasticandClaude Opus 5.5 fa3d78a4b2 Fix #696: the Properties panel's number fields are all one width
The Transform fields were sized from the row's room, the rotation field was
70 pt, the corner radius 80, Opacity 64, the stroke weight 90 and Keyboard
Increment 80, so the fields didn't line up. One `panels::field_width`, the
Transform fields' width measured from the full row, now sizes all of them (and
the Edit Artboards fields, and the Transform panel's corner radius).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:54:25 +07:00
llduyll10andClaude Opus 5.5 1056a42a56 Open: a saved .ai with several artboards restores its document instead of asking for pages
Opening a PDF-compatible .ai (or a PDF saved with Preserve Editing) that has more than one
page showed the Import PDF dialog and imported pages, although the file carries its
VectorCraft document. Opening now restores that document (every artboard and layer) without
asking, as the engine already does when no pages are picked. Placing still asks for the page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:48:37 +07:00
BFlat d464f28ed3 Merge pull request #697 from storytold/pr/land-694
Land #694: Radial Blur, Smart Blur and Unsharp Mask
2026-10-09 13:47:27 +07:00
bflatasticandClaude Opus 5.5 83b51ab9c7 SVG export: name the unsupported pixel effects without a product name
The brands check keeps other products' names out of user-facing text; #694's
SVG warnings called Radial Blur, Smart Blur and Unsharp Mask "Photoshop-style
raster effects". They are "pixel effects" now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:43:49 +07:00
callmeBallochandClaude Opus 5.5 3e94f6ffd6 M2.9: hexagon orientation no longer depends on atan2 rounding
The Shaper fits a hexagon's rotation by scoring 0, 90, 180 and 270
degrees, but a hexagon at 180 is the one at 0 (and 270 the one at 90):
those pairs score the same up to rounding, so the platform's atan2 picked
between them. FreeBSD's libm gave 270 where Linux gives 90, failing
tilted_rectangles_ellipses_and_hexagons_snap_to_their_angle_steps on the
FreeBSD build+test job. Candidates a whole number of periods from an
earlier one are now dropped. A new test reproduces the flip on Linux
with the old code (180 instead of 0 at tilt -9).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
2026-10-09 06:37:59 +00:00
bflatastic 5acc269a41 Merge #694: Radial Blur, Smart Blur and Unsharp Mask
# Conflicts:
#	ROADMAP.md
2026-10-09 13:36:38 +07:00
BFlat c4e20023de Merge pull request #695 from storytold/fix/693-artboard-paste
Fix #693: a click activates its artboard; in-place pastes go onto it
2026-10-09 13:36:14 +07:00
bflatasticandClaude Opus 5.5 e9bbf43d15 Fix #693: a click activates its artboard; in-place pastes go onto it
- A press with a selection tool inside an artboard makes it the active one, as
  the Artboard tool's does; before, only the navigator and the Artboards panel
  changed it.
- Paste in Place, in Front and in Back take `artboard` (the UI passes the active
  one): the objects go where they were on the artboard they were copied from,
  on that artboard. Without it, they paste where they were, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:32:44 +07:00
bflatasticandClaude Opus 5.5 1e7f5084d8 Part of ROADMAP gap 2: Radial Blur, Smart Blur and Unsharp Mask on a pixel-filter pipeline
Effect › Blur › Radial Blur (spin/zoom, draft/good/best) and Smart Blur
(Normal mode), Effect › Sharpen › Unsharp Mask: Photoshop-style filters in
vectorcraft_effects::pixel, run by the renderer on the object's pixels in
stack order with Gaussian Blur and Feather, cached while panning; lengths in
points, bounded work per pixel. PDF, Expand Appearance and SVG export write
the effected objects as images. Dialogs with preview (method/quality
dropdowns, fields in documented order), menu entries, translations, tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:30:31 +07:00
BFlat 843507549f Merge pull request #692 from storytold/fix/windows-taskbar-icon
Windows: the taskbar and Alt+Tab show VectorCraft's icon
2026-10-09 13:15:39 +07:00
bflatasticandClaude Opus 5.5 b554330d06 Windows: the taskbar and Alt+Tab show VectorCraft's icon
Windows shows a window's big icon (ICON_BIG) in the taskbar and Alt+Tab.
eframe sets only the window icon (the small one, in the title bar), so the
taskbar showed a generic window instead of VectorCraft's icon. The app now sets
the big icon from the same 256 px image when the window is created.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:12:25 +07:00
BFlat eec150e276 Merge pull request #691 from storytold/pr/land-667
Land #667: native Affinity import with artboards, vectors, text and images
2026-10-09 12:56:00 +07:00
bflatastic 2de7257736 Merge #667: native Affinity import with artboards, vectors, text and images 2026-10-09 12:48:31 +07:00
BFlat 256702bab0 Merge pull request #690 from storytold/fix/663-toolbar-labels
Part of #663: Show Tool Group Labels option; Cmd+, opens Settings on the Mac
2026-10-09 12:44:53 +07:00
bflatasticandClaude Opus 5.5 498a2d1e6b Part of #663: Cmd+, opens Settings on the Mac
Cmd+, is how every Mac app opens its settings. It now opens Preferences on
macOS beside its own Cmd+K, unless a command or one of the user's shortcuts
already has the chord.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:38:29 +07:00
bflatasticandClaude Opus 5.5 4ab13dabfb Part of #663: User Interface > Show Tool Group Labels
The toolbar names its tool groups (Select, Shapes, Draw, Modify...). A new
preference, on by default, turns the names off for a cleaner toolbar: a faint
dash then separates the groups.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:38:28 +07:00
BFlat f1157456a1 Merge pull request #689 from storytold/pr/land-687
Land #687: Rearrange All Artboards layouts, order and one click (fix #681)
2026-10-09 12:33:16 +07:00
bflatastic 55cc14e4a8 Merge #687: Rearrange All Artboards layouts, order and one click
# Conflicts:
#	ROADMAP.md
2026-10-09 12:28:10 +07:00
BFlat dce0f8a48d Merge pull request #688 from storytold/fix/673-tab-close-side
Part of #673: a document tab's close box sits where the platform puts it
2026-10-09 12:27:41 +07:00
bflatasticandClaude Opus 5.5 6a39ee0a1c Part of #673: a document tab's close box sits where the platform puts it
Document tabs drew their close box before the title on every platform, the
macOS convention. On Windows, Linux and the web it now comes after the title,
as those platforms' tabs (and Adobe's own apps there) put it; macOS keeps it
before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:24:25 +07:00
bflatasticandClaude Opus 5.5 01df2c730b Fix #681: Rearrange All Artboards layouts, order and one-click access
- artboard.rearrange takes layout (gridByRow, gridByColumn, row, column;
  byColumn stays as an alias of gridByColumn) and order (leftToRight,
  rightToLeft); Columns is the row count of a grid by column and is
  ignored by row and column. It returns {artboards, rows, columns}.
- A Rearrange All Artboards dialog: Layout and Layout Order toggles drawn
  in code (widgets::layout_button, previewing the order), Columns/Rows,
  Spacing, Move Artwork with Artboard and the artboard count.
- One click: Rearrange All in the Properties panel (Document section with
  several artboards, Edit Artboards' Quick Actions); the Artboards panel's
  Rearrange button and menu item now open the dialog too.
- Tests for every layout x order (artboards, art riding along, one undo
  step), the dialog, the Properties button; the command sweep covers the
  new params. Translations for ja, es, it, ru, fr, zh-hans, zh-hant and
  pt-br; ROADMAP updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:22:48 +07:00
BFlat dcc0de7487 Merge pull request #686 from storytold/fix/663-canvas-greys
Part of #663: Canvas Color offers light, medium and dark grey
2026-10-09 12:20:42 +07:00
callmeBallochandClaude Opus 5.5 761b8fc080 AF.3: state the Affinity reader's clean-room provenance
The crate README now spells out the provenance the review on
storytold/vectorcraft#667 asks the owner to decide on: no Affinity
software; afread's MIT description re-described without translating
code; every structure confirmed on public files (CRC-32 per entry,
self-typed fields); semantics fitted to Affinity's own thumbnails; how
the sample documents were licensed; read-only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
2026-10-09 05:20:33 +00:00
callmeBallochandClaude Opus 5.5 5d41601da1 Merge storytold/main into AF.3 native Affinity import
Conflicts: ROADMAP (main's guides and file-format rows, with the Affinity
items kept); fileio/load.rs (main's physical-size raster documents, kept
visible to the Affinity preview fallback).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
2026-10-09 05:20:33 +00:00
bflatasticandClaude Opus 5.5 0924bc16ae Part of #663: Canvas Color offers light, medium and dark grey
Preferences > User Interface > Canvas Color offered Match User Interface
Brightness and White only: with a dark interface the first is a high-contrast
dark canvas around the white page, and White hides where the page ends. Light
Gray, Medium Gray and Dark Gray keep the page's edge in view at a gentler
contrast.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:14:33 +07:00
callmeBallochandClaude Opus 5.5 e80e47a295 AF.3: count distinct sample documents in the value-budget note
The census behind the limit counted duplicate copies of the same public
files: there are 189 distinct documents, the largest still decoding 3.3
million values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
2026-10-09 05:13:22 +00:00
BFlat f18cd1c99b Merge pull request #685 from storytold/fix/683-fonts-folder
Fix #683: Preferences › Type › Additional Fonts Folder
2026-10-09 12:11:11 +07:00
bflatasticandClaude Opus 5.5 e92bbdbdac Fix #683: Preferences > Type > Additional Fonts Folder
VectorCraft found fonts only in the system's font folders (and, on Windows,
those font services activate). A new preference names one more folder, read
with its subfolders after the platform's: its fonts are listed in the font
menus and used as if installed, without installing them.

- vectorcraft_text::set_user_font_dirs / user_font_dirs: folders
  system_font_dirs adds to every scan (none on wasm).
- prefs fontsFolder (Type > Options > Additional Fonts Folder), trimmed; when
  it changes after the first scan, the fonts are scanned again at once, so the
  folder's fonts appear (or go) and type in them lays out again.
- The label is translated where Additional Plug-ins Folder is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:05:16 +07:00
BFlat b61089e5c6 Merge pull request #684 from storytold/pr/land-fastmikey
Land #674, #676, #677, #678, #679: raster resolution, Crop Image trim, CMYK TIFF alpha, masked CMYK PDF images, direct ICC CMYK to RGB
2026-10-09 11:56:43 +07:00
bflatasticandClaude Opus 5.5 7bd496b1e2 Images: the web app keeps the decoder's 512 MB limit
#677 lets one image decode take up to 2 GB, so large print sheets open on the
desktop. wasm32 can address only 4 GB and aborts when an allocation fails, so
the web app keeps the decoder's default 512 MB and reports larger images as an
error instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:43:59 +07:00
bflatastic 2b91d7effd Merge #679 2026-10-09 11:43:42 +07:00
bflatastic b062d625cf Merge #678 2026-10-09 11:43:42 +07:00
bflatastic ee7be83ee0 Merge #677 2026-10-09 11:43:41 +07:00
bflatastic 50bb3b2693 Merge #676 2026-10-09 11:43:41 +07:00
bflatastic 2f81b4437b Merge #674 2026-10-09 11:43:40 +07:00
BFlat 23b7c68d90 Merge pull request #680 from storytold/fix/671-artboard-orientation
Fix #671: Edit Artboards' Properties picks a size preset and flips the orientation
2026-10-09 11:42:03 +07:00
bflatasticandClaude Opus 5.5 4176c0f297 Fix #671: Edit Artboards' Properties picks a size preset and flips the orientation
With the Artboard tool, the Properties panel showed the active artboard's name,
position and size but no quick way to turn it between portrait and landscape.
It now has a row with:
- Preset: New Document's saved and built-in presets, by category, matched in
  either orientation (Custom when none matches); choosing one sizes the
  artboard in its current orientation;
- Portrait and Landscape (the New Document toggle): the other one swaps the
  artboard's width and height.
The artboard's top-left corner stays put, so no art moves with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:37:05 +07:00
BFlat 349668a1e6 Merge pull request #672 from storytold/pr/land-394
Land #664, #669: Animated Zoom, Large Tabs, Scale Cursor, anti-aliased bitmaps, Spacing Guides (part of #394)
2026-10-09 11:32:46 +07:00
callmeBallochandClaude Opus 5.5 67a62a7fe7 AF.3: bound the values a document stream decodes
A value takes about 40 bytes in memory however few it took in the file,
and a bool array packs eight in a byte, so a small, highly compressed
doc.dat could declare billions of values and make the import abort on
allocation (review on storytold/vectorcraft#667). The parser now counts
fields and array elements against a 2^24 budget before allocating, and
reserves array storage fallibly, returning Error::Limit either way.
The largest of 333 public documents decodes 3.3 million values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
2026-10-09 04:31:42 +00:00
callmeBallochandClaude Opus 5.5 b5ae419734 Merge storytold/main into AF.3 native Affinity import
Conflicts: README and ROADMAP (both sides kept: the French and Russian
interface with the Affinity sentence; the placed-documents bullet with
the Affinity one); place and swatch libraries (upstream's byte sniffing
and comments, with the preview-only refusal kept).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
2026-10-09 04:26:07 +00:00
bflatasticandClaude Opus 5.5 ea07805fdf ROADMAP: Spacing Guides wired (#669) beside #664's preferences
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:26:01 +07:00
bflatastic e29e558810 Merge fix/394-spacing-guides
# Conflicts:
#	ROADMAP.md
2026-10-09 11:25:41 +07:00
bflatastic cc627c75e3 Merge fix/394-prefs-batch
# Conflicts:
#	ROADMAP.md
2026-10-09 11:25:38 +07:00
BFlat 5f4e03f3b3 Merge pull request #670 from storytold/fix/661-touch-bar-quit-int
Fix #661: quitting no longer crashes on Macs with a Touch Bar
2026-10-09 11:24:59 +07:00
fastmikey99andClaude Opus 5.5 f767403673 Crop Image: trim an image to its pixels that aren't fully transparent
object.cropImage {trim: true} cuts the selected image to the box of its pixels
with any alpha, copying them as they are (no re-render, no resampling) and
placing them where they were. An image with nothing to cut is left as it is
and isn't an error (trimmed: false), so a script can trim every image it
opens. Print-ready PNGs usually carry transparent padding; with this, Fit to
Artwork Bounds can size to the art.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:24:04 +13:00
bflatasticandClaude Opus 5.5 90d6785eb5 Part of #394: Animated Zoom, Large Tabs, Scale Cursor and anti-aliased bitmaps act
- Performance › Animated Zoom (with GPU Performance, which dims it when
  off): the Zoom tool dragged sideways zooms about the press (right in,
  left out) and held still zooms on (Alt: out); a click still steps. Off,
  a drag zooms to the area dragged across, now drawn as a marquee. The
  hint bar says which.
- User Interface › Large Tabs: taller document tabs with larger titles.
- User Interface › Scale Cursor Proportional to UI: off, the tool cursors
  keep their size whatever the UI Scaling (OS and painted cursors alike).
- File Handling › Display Bitmaps as Anti-aliased Images in Pixel
  Preview: off (the default), Pixel Preview samples images nearest-pixel
  (`RenderOptions::smooth_images`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:22:43 +07:00
bflatasticandClaude Opus 5.5 1575b14294 Part of #394: Smart Guides › Spacing Guides space a moved selection evenly
A selection moved with the Selection or Direct Selection tool that lines
up with nothing along an axis lands as far from its neighbour as two
other objects of its row (or column) are apart, or halfway between its
two neighbours, within the snapping tolerance; the equal gaps are marked
with a line and end ticks in the Smart Guides colour. The objects are
taken as wholes on their layers (groups too), and inside a group holding
the selection, its other objects. Off, moves only line up as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:22:22 +07:00
bflatasticandClaude Opus 5.5 f17bac2c64 Fix #661: quitting no longer crashes on Macs with a Touch Bar
On every quit (Cmd+Q, VectorCraft > Quit, the close button) on a MacBook Pro
with a Touch Bar, AppKit threw from -[_NSTouchBarFinderObservation invalidate]
and macOS reported a crash. AccessKit's macOS adapter subclasses the window's
content view and gives it back its own class when the window is torn down;
AppKit's Touch Bar support observes the view under the subclass, so removing
that observation throws (the same cause Zed fixed in zed-industries/zed#65186).

On macOS with accessibility, the app now ends the process at the end of
on_exit, once the preferences are saved, before eframe tears the window down.
Nothing after the window is needed on a normal quit. While the window is still
starting up it doesn't, so a graphics failure still starts the app again on
another adapter (#651).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:20:40 +07:00
BFlat 01ad745e86 Merge pull request #668 from storytold/fix/663-recent-and-preset-dblclick
Part of #663: Home screen Recent Files; a double-clicked preset creates the document
2026-10-09 11:20:12 +07:00
bflatasticandClaude Opus 5.5 d8f6bacddb Part of #663: Home screen Recent Files; a double-clicked preset creates the document
- The Home screen lists the first six of File > Open Recent Files, each by name
  (a click opens it) and folder; one that can't be opened says so in the status
  bar. With none, there is no section.
- In New Document, a double-click on a preset card creates the document from
  it; a click still only chooses it. The Create buttons no longer overwrite a
  create asked for earlier in the frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:17:35 +07:00
fastmikey99andClaude Opus 5.5 2cdf4e3de0 Colour: ICC CMYK converts straight into the RGB profile, with black-point compensation
CMYK went to RGB through display sRGB: CMYK -> sRGB (clamped) -> working RGB.
Into a wider space (Wide Gamut RGB, the 1998 primaries) that clipped every
colour sRGB can't hold - CMYK cyan's red channel landed at 99 instead of 0 -
and the black-point compensation setting had no effect (moxcms leaves it
unimplemented). An ICC CMYK profile now converts directly into the RGB
profile: into its primaries with linear curves, where compensation is a
linear remap from the profile's black (found by sending black through the
profile and reading it back), then onto its own curves. The canvas uses the
same conversion into sRGB, so what shows is what an RGB export writes.

Checked against Ghostscript with the same profiles (relative colorimetric,
compensation on and off): exports into Wide Gamut RGB and the sRGB display
agree within 1 level in 255. The built-in CMYK spaces are unchanged (Generic
CMYK compensates in its own model).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:11:34 +13:00
fastmikey99andClaude Opus 5.5 088495ee17 PDF import: masked CMYK images stay CMYK, their mask an opacity mask
A CMYK image with an /SMask, /Mask or colour key was handed to the
interpreter and stored as an RGB PNG, converted with hayro's own bundled
profile (Perceptual) rather than the working CMYK, so it showed 10-30 levels
lighter than other CMYK images and lost its ink amounts. It now keeps its
inks as other CMYK images do (#320), and its alpha becomes a greyscale
opacity mask. PDF export already writes that as a DeviceCMYK image under a
soft mask, so a round trip keeps the inks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:11:34 +13:00
fastmikey99andClaude Opus 5.5 43db1cdbbf Open CMYK TIFFs with an alpha channel, and rasters past 512 MB
The image decoder can't read a CMYK TIFF with an alpha channel at all
("color type Unknown(0)"); it is now read with the tiff crate, associated
alpha divided back out and the inks converted in the active colour settings.
And a 60 in sheet at 300 ppi (18000 x 6600 px) hit the decoder's 512 MB
default allocation limit; up to 2 GB is now allowed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:11:08 +13:00
fastmikey99andClaude Opus 5.5 ed17166343 Open raster images at the size their resolution declares
An image opened as a document took 1 px = 1 pt whatever its pHYs, JFIF or
EXIF resolution said, so a 300 ppi print file opened at four times its
physical size. It now opens the way Place already sizes it (pt_per_px):
at the resolution it declares, 72 ppi when it declares none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:11:07 +13:00
BFlat fc74389b5a Merge pull request #660 from Haven-King/fix/text-engine-integration
Fix/text engine integration
2026-10-09 10:45:11 +07:00
BFlat 0891ffbf51 Merge pull request #659 from storytold/fix/449-two-finger-pan
Part of #449: two fingers dragged on a touch screen pan the canvas
2026-10-09 10:37:26 +07:00
bflatasticandClaude Opus 5.5 861a0759b0 Part of #449: two fingers dragged on a touch screen pan the canvas
A pinch already zoomed the canvas and a second finger already cancelled the
first finger's press, but dragging two fingers together did nothing: the
canvas only read wheel scrolls, and a touch screen sends touches. The fingers'
movement (egui's multi-touch translation) now scrolls the canvas under either
Zoom with Mouse Wheel setting, and zoom and scroll apply in the same frame, so
a pinch that also slides pans too. Trackpads send scrolls, so nothing counts
twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:34:22 +07:00
haven 992f6a0a4d M7: tests for the text engine features combined
Layout (vectorcraft-text tests_combos): every pair of ligatures under tracking,
the paragraph composer, vertical alignment, Shrink Text to Fit, per-paragraph
attributes and inline graphics, and all six at once. Commands
(vectorcraft-engine tests_textcombos): Auto Size and Shrink Text with inline
art, paragraphs, composer and alignment; editing and undo across them; a save
round trip with everything on.
2026-10-08 20:30:14 -07:00
haven 53e39884fc M7: Auto Size measures vertically aligned area type from the top
auto_height lays the text out in a very tall probe frame; with Center, Bottom
or Justify the lines moved down inside it and the frame grew to tens of
thousands of points.
2026-10-08 20:30:14 -07:00
haven abdc26d8d1 M7: Shrink Text to Fit scales inline art's baseline shift
The fit scaled each run's size, leading and baseline shift but not an inline
graphic's own shift (points), so shrunk symbols sat off the cap-height centre.
2026-10-08 20:30:14 -07:00
haven 56c975465c M7: inline art follows vertically aligned lines
align_vertically moved lines and glyphs but not TextLayout::inlines, so inline
symbols in a centred or bottom-aligned area stayed where top alignment put them.
The reflow path for non-rectangular frames also kept the previous pass's
inlines, drawing each symbol twice.
2026-10-08 20:30:14 -07:00
BFlat 2dcac5b48b Merge pull request #658 from storytold/fix/651-gpu-fallback-int
Fix #651: a dual-GPU Mac falls back to its other GPU
2026-10-09 10:29:33 +07:00
bflatasticandClaude Opus 5.5 dc39146d0d Fix #651: a dual-GPU Mac falls back to its other GPU
Metal reports no PCI ids, so both GPUs of a dual-GPU Mac had the adapter key
Metal:0000:0000. When the integrated GPU failed at start-up, the restart left
out both and the app quit, although the discrete GPU works. An adapter without
ids is now keyed by its name, so the restart leaves out only the one that
failed.

The failure itself was wgpu's indirect-call validation compute shader, which
OCLP-patched Metal on an Iris Pro can't compile. Nothing in VectorCraft or
egui draws indirectly, so that validation is off (WGPU_VALIDATION_INDIRECT_CALL=1
turns it back on).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:26:55 +07:00
BFlat 147fc113c3 Merge pull request #657 from storytold/pr/land-641
Land #653, #654, #656: Image Trace View modes, Palette and Create Strokes (part of #641)
2026-10-09 10:25:04 +07:00
bflatastic 66a5363629 Merge #656: Image Trace: Create Strokes
# Conflicts:
#	ROADMAP.md
#	crates/engine/src/cmd/buildcmds.rs
#	crates/engine/src/tests_build.rs
#	crates/mcp/src/prompts.rs
#	crates/trace/src/tests.rs
#	crates/ui-egui/src/panels/image_trace.rs
2026-10-09 10:16:02 +07:00
bflatastic eea4fe9e58 Merge #654: Image Trace: Palette (Limited, Full Tone, Automatic, Document Library)
# Conflicts:
#	ROADMAP.md
#	crates/engine/src/cmd/buildcmds.rs
#	crates/ui-egui/src/panels/image_trace.rs
2026-10-09 10:14:35 +07:00
bflatastic 13c37e0bf4 Merge #653: Image Trace: Advanced as a section that stays open, and View modes
# Conflicts:
#	ROADMAP.md
2026-10-09 10:13:04 +07:00
bflatasticandClaude Opus 5.5 438f90c751 Part of #641: Image Trace: Create Strokes
Advanced › Create: Fills and/or Strokes, with Stroke (the widest line, px).
With Strokes, each colour's 8-connected parts no wider than the stroke width
(chamfer distance transform) trace as open centre lines instead of filled
outlines: the line is thinned to one pixel (Zhang-Suen with each removal
re-checked, so two-pixel lines survive; staircase corners dropped), its
skeleton becomes runs between ends and junctions (junction pixels merged,
spurs pruned, runs through two-way junctions joined, rings closed), and each
run is simplified and fitted with curves. The stroke width is the line's mean
width (pixels over length). Traced lines get round caps and joins in the
document. Without Fills, wider areas become 1 px stroked outlines.
`imageTrace.make` takes fills, strokes and strokeWidth, and refuses neither
fills nor strokes and a non-positive width.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:11:09 +07:00
bflatasticandClaude Opus 5.5 4a81e549a2 Part of #641: Image Trace: Palette (Limited, Full Tone, Automatic, Document Library)
Color mode's colours now come from a Palette (`palette` in imageTrace.make's
params), shown in the Image Trace panel under Mode:
- Limited: the `colors` best colours (what Color did before; the default).
- Full Tone: median cut until no colour box spans more than 96 (Less) … 8
  (More) levels per channel, up to 256 colours, set by `colorDetail` 0-100
  (the panel's Colors slider for this palette).
- Automatic: flat art (90% of the pixels in a few tight colours) traces with
  exactly its colours, anything else as Full Tone.
- Document Library: the most used (at most `colors`) colours of the
  document's swatches or of a swatch library (`library`: "document" or a
  library id or name, chosen in the panel's Library list), used as they are;
  traced shapes fill with the swatch's own colour, its colour model kept.
  The library's colours are looked up at every trace, not saved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:51:49 +07:00
bflatasticandClaude Opus 5.5 5be583d1f7 Part of #641: Image Trace: Advanced as a section that stays open, and View modes
- The Image Trace panel's Advanced options sit under a full-width collapsible
  header (widgets::section_toggle, also used by New Document's Advanced
  Options); whether it is open is kept with the UI preferences.
- View: Tracing Result, Tracing Result with Outlines, Outlines, Outlines with
  Source Image and Source Image, in the panel, the Control bar and the
  Properties panel. The view is saved in the Image Trace object's settings
  (`trace.view`), kept when it is traced again, set with the new
  `imageTrace.setView` command (or `view` on imageTrace.make) and drawn by the
  canvas only (RenderOptions::trace_views): exports, printing and Expand keep
  the tracing result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:33:22 +07:00
BFlat 11fceda13e Merge pull request #650 from mateususa/fix/artboard-tool-delete-key
Artboard tool: Delete and Backspace delete the active artboard
2026-10-09 09:31:38 +07:00
BFlat ea814b3fac Merge pull request #652 from storytold/fix/649-dock-drop-zone
Fix #649: a panel dropped on the dock lights up where it goes
2026-10-09 09:27:11 +07:00
bflatasticandClaude Opus 5.5 b5e39048df Fix #649: a panel dropped on the dock lights up where it goes
Panels other than Properties, Layers and Libraries live in the dock's icon
column, not as tabs. Dragging one onto the dock lit up the whole dock, tabs
included, and the drop put it back as an icon, so it seemed to vanish. The
drop zone now lights up the icon column for such panels (the whole dock still
for the tabbed ones), and the dropped panel pops out of its icon.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:23:30 +07:00
mateususaandClaude Opus 5.5 762bd6b927 Artboard tool: Delete and Backspace delete the active artboard
The Artboard tool handled Delete/Backspace in Tool::key, but never claimed
the keys through Tool::claims_key. The UI only routes Delete/Backspace to a
tool that claims them, so the keypress went to the Clear shortcut instead
and the artboard stayed. The existing tests called the tool directly and
missed it.

The tool now claims Delete/Backspace while no drag is in progress and more
than one artboard exists. With only one artboard left, the keys stay with
the shortcut.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 19:19:59 -07:00
BFlat 033ceaff41 Merge pull request #648 from storytold/fix/svg-root-layer-name
Part of #372: SVG import names the one layer from the root element
2026-10-09 09:05:21 +07:00
bflatasticandClaude Opus 5.5 a2e4206155 Part of #372: SVG import names the one layer from the root element
Other editors export a document with a single layer without a layer group:
the layer's name goes on the root <svg> (id, or data-name) and its art sits
directly inside. Such a file opened as "Layer 1"; now its one layer takes the
root's name. Files whose top-level groups are the layers are unchanged, and an
Inkscape root's generated id (with inkscape:version) isn't taken as a name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:00:24 +07:00
BFlat 771981e2b1 Merge pull request #644 from dotCooCoo/feat/ase-import
Swatches: swatch exchange (.ase) files open as swatch libraries
2026-10-09 08:50:50 +07:00
BFlat c7aeaa637f Merge pull request: land #643 (fix #638)
Land #643 (fix #638: Layers panel lays out only rows in view, keeps open rows in the file)
2026-10-09 08:40:24 +07:00
bflatastic 965fc449e1 Merge #643: Fix #638: Layers panel lays out only the rows in view and keeps open rows in the file 2026-10-09 08:31:13 +07:00
bflatasticandClaude Opus 5.5 977e5d1967 Fix #638: Layers panel lays out only the rows in view and keeps open rows in the file
The panel laid out a row (with its thumbnail and several whole-document
lookups) for every open row each frame. A layer of 5,000 groups of 10 paths,
all open, took about 29 s a frame headless. Now the rows shown are listed once
per document change (with their layer colour and dimming worked out on the
way down) and ScrollArea::show_rows lays out only those in view: 0.4 ms a
frame. A row highlighted out of view is scrolled to.

The open rows move from UI state into the document's state
(DocState::layers_open, OpenRows): a document opens with only its top-level
layers open, and native files save the open rows (Document::layers_open,
written only when not the default, at save time like the view, so opening or
closing rows never marks the document modified).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 08:19:49 +07:00
BFlat b73e19eef2 Merge pull request: land #639 (part of #372)
Land #639 (part of #372: SVG import keeps wrapped layers, round-trips sublayers)
2026-10-09 07:45:18 +07:00
bflatastic 4792bd82a2 Merge #639: Part of #372: SVG import keeps wrapped layers and round-trips sublayers 2026-10-09 07:41:02 +07:00
bflatasticandClaude Opus 5.5 d5e6be1565 Part of #372: SVG import keeps wrapped layers and round-trips sublayers
Top-level groups already opened as layers named from data-name, else their
id. Now that also holds when one more id-less group wraps them (a transform
on all the art, or a <switch>'s chosen group), and hidden objects inside a
<switch> are kept (hidden) instead of dropped.

Nested groups stay groups unless something marks them as layers: the SVG
export now writes data-vc-layer="sublayer" on layers inside layers, and
import makes such a group (or an Inkscape layer group) a sublayer of the
layer holding it, in that layer's colour. A marked group anywhere else
stays a group.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 07:36:21 +07:00
BFlat a469568c7d Merge pull request #636 from HEOJUNFO/pixel-preview-pixels
View › Pixel Preview: show the document pixels the art rasterizes to
2026-10-09 06:57:47 +07:00
heojunfoandClaude Opus 5.5 53b4845f6f View › Pixel Preview: show the document pixels the art rasterizes to
Pixel Preview only switched the pixel grid on from 600%: the art was still
rendered for the screen and drawn with linear filtering, so curves and
strokes stayed smooth vectors. Now, while a document pixel is bigger than a
screen pixel, the canvas renders the visible document pixels one per point
(anti-aliased by coverage), uploads them with nearest filtering and places
them over the document rect they cover, turning with a rotated view.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 08:49:18 +09:00
BFlat 5f92f5eb7f Merge pull request: land #631, #632
Land #631 (ICF character alignment) and #632 (French interface)
2026-10-09 06:34:55 +07:00
bflatasticandClaude Opus 5.5 409af66699 Tests: #631's ICF test builds its runs with #627's inline field
#627 added TextRun::inline while #631 was open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 06:26:49 +07:00
bflatasticandClaude Opus 5.5 65f35f20a6 French: ICF Top/Right and ICF Bottom/Left (#631 landed beside the catalog)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 06:25:14 +07:00
bflatastic 2b10c15d7a Merge #632: Add French (fr) interface translation
# Conflicts:
#	ROADMAP.md
2026-10-09 06:24:09 +07:00
bflatastic 712f4316e0 Merge #631: Type: Character Alignment on the ICF top and bottom (ideographic character face)
# Conflicts:
#	ROADMAP.md
#	crates/text/src/layout.rs
2026-10-09 06:24:04 +07:00
Simon LeblancandClaude Opus 5.5 0716a0d104 Translate the French labels added on main
Free Transform's widget (Free Transform, Perspective Distort,
Constrain), the Properties panel's artboard editing (Artboard, Exit)
and Type › Insert Inline Symbol, found by the catalog completeness
tests after the rebase.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 01:19:54 +02:00
BFlat a29563fb92 Merge pull request: land #629
Land #629 (pasted type keeps its layout bounds)
2026-10-09 06:18:57 +07:00
Simon LeblancandClaude Opus 5.5 c640a04612 Translate the French strings drawn through translating helpers
These labels reach the screen through helpers that translate their
argument (dim_label, dropdown, tl!(CONST)), so the tl! literal scan
doesn't see them: the Libraries panel's empty state, Recolor Artwork's
1/2/3 Color Job presets, Spot Colors' CMYK Equivalents, the
Eyedropper's 3 x 3 / 5 x 5 Average, Print's PDF File destination and
Save PDF's CMYK profile in effect output intent.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 01:18:25 +02:00
Simon LeblancandClaude Opus 5.5 e22d22a4ce Order the French code and docs alphabetically by language code
French now sits between Spanish and Italian (es, fr, it, ru): the
plural rule, the LANGUAGES registry entry (so the Language menu lists
Français before Italiano), the locale tests, the fr() helper and
french_reads_as_french. README, ROADMAP and docs/development.md list
the languages in the same order.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 01:18:25 +02:00
Simon LeblancandClaude Opus 5.5 9ec940222b Add French (fr) interface translation
Add fr.tsv with the full interface coverage: every menu string, every
tl! literal, the plurals and the status/error messages (@msg).
Registered as complete_menus with the French plural rule (0 and 1 are
singular); every fr-* locale (fr-FR, fr-BE, fr-CA, fr-CH …) resolves
to it. Keeps the product, workspace and perspective preset names in
English like the other complete catalogs.

Tests: french_reads_as_french; fr added to KEEPS_MENU_NAMES and
COMPLETE_MESSAGES; the Latin-script glyph test covers French letters
and the no-break space; locale tests use de-DE as the unsupported case.

Clean-room: written from the meaning of the English labels in the
vocabulary French-speaking users of vector illustration software know
(Plan de travail, Nuancier, Contour, Fond, Tracé, Calque). No other
application's localisation was copied.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 01:18:14 +02:00
bflatastic 9e2fb37f3a Merge #629: Fix pasted type losing its layout bounds cache
# Conflicts:
#	crates/engine/src/cmd/edit.rs
2026-10-09 06:14:31 +07:00
imachusandClaude Opus 5.5 4411688b3d Type: Character Alignment on the ICF top and bottom (ideographic character face)
Character panel menu › Character Alignment gains ICF Top/Right and ICF Bottom/Left, and
`charAlign` takes `icfTop` / `icfBottom`: a character smaller than the largest on its line lines
up its ideographic character face's top (right, vertical) or bottom (left) with the big one's.

`FontFace::icf_margins` places the ICF inside the em box as the OpenType baseline tags define it
(https://learn.microsoft.com/en-us/typography/opentype/spec/baselinetags): `icfb` / `icft` of the
BASE table's horizontal and vertical axes against `ideo` / `idtp` (else the OS/2 typographic
descender and one em), for Han ideographs (else kana, else the first script). Without them, as that
page allows, the average ink box of some ideographs and kana; a face without ideographs uses its
em box.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 08:14:17 +09:00
BFlat 6b2c90f756 Merge pull request: land #627, #628
Land #627 (inline symbols in text) and #628 (comment cleanup)
2026-10-09 06:13:34 +07:00
Diego Gomez Garcia 1ee36c2c07 Fix pasted type losing its layout bounds cache
Type pasted or imported from another app (Illustrator, PDF, SVG) arrives
without cached_bounds, which is not serialized. TextObject::bounds() then
falls back to estimate_bounds(), a rough 0.55 em per character, so the
selection box came up short of the last character and alignment looked off
until the file was saved and reopened (Session::add_document refreshes it).

Refresh the bounds of pasted text in paste_clip, covering paste and paste in
front/back/in place and every clipboard flavour, with a regression test.
2026-10-09 01:05:11 +02:00
bflatastic f365dc1c74 Merge #627: M7: inline symbols in text 2026-10-09 06:02:59 +07:00
bflatastic e9de045f06 Merge #628: Type: comments on new type's burasagari default state the default only 2026-10-09 06:02:58 +07:00
BFlat 58a3a68d83 Merge pull request #625 from DWAA1660/fix/536-popover-swatch-libraries
Fix #536: the Fill/Stroke popover's Swatch Libraries button opens its menu
2026-10-09 06:02:12 +07:00
imachusandClaude Opus 5.5 a366e4ff79 Type: the comments on new type's burasagari default state the default only
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 08:01:16 +09:00
BFlat 8b81d58845 Merge pull request: land #568, #570, #623
Land #568 (fix #563), #570 (fix #530), #623 (part of #579)
2026-10-09 05:57:51 +07:00
Claude d9b5eea18d Fix #536: the Fill/Stroke popover's Swatch Libraries button opens its menu
In the Fill or Stroke chip's popover (Control bar, Properties ›
Appearance), the Swatch Libraries Menu button at the foot of the
swatches closed the popover and showed nothing, as in the video on
#536; Show Swatch Kinds did the same. egui remembers one open popup per
window: the chip's popover was such a remembered popup, so the button's
menu, opening as another one, closed the popover and itself with it.

The popover now keeps its own open state with widgets::popover (made for
popovers that open menus of their own), so the libraries menu opens over
it and a library chosen there opens in the library panel. Escape, a
click outside and a click on the chip still close it.

Test: tests_paintchips::the_popover_swatch_libraries_button_opens_the_libraries_menu
(fails before this change: the popover closes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3vVft4VXvvy8SpbbUceTW
2026-10-08 16:53:57 -06:00
bflatasticandClaude Opus 5.5 8ccafe1187 Russian: Artboard and Exit for the Properties panel's artboard editing
#570 added them while the Russian catalog was landing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 05:53:30 +07:00
bflatastic f3b90fb93f Merge #623: Part of #579: vectorcraft-cli lists the fonts font services load too
# Conflicts:
#	ROADMAP.md
2026-10-09 05:48:40 +07:00
bflatastic fc052cb001 Merge #570: Fix #530: Edit Artboards adds, deletes and leaves artboards from Properties
# Conflicts:
#	ROADMAP.md
2026-10-09 05:48:30 +07:00
bflatastic 896f8efea8 Merge #568: Fix #563: the font menu's live preview no longer flickers
# Conflicts:
#	ROADMAP.md
#	crates/ui-egui/src/tests_font_menu.rs
2026-10-09 05:48:00 +07:00
BFlat 921e60f4fc Merge pull request: land #621 (fix #585)
Land #621 (fix #585: two-finger tap undoes, three-finger tap redoes)
2026-10-09 05:46:59 +07:00
haven b843fe8af3 M7: inline symbols in text
Document symbols placed inline in a run of type (beyond Illustrator; like
InDesign inline anchored objects): TextRun.inline holds the symbol, scale and
shift; the run is one U+FFFC laid out as a glyph sized to the art, centred on
the cap height. text.insertInline and Type > Insert Inline Symbol; editing
keeps the graphic whole; canvas, SVG (<use>) and PDF draw the art; Create
Outlines turns it into a symbol instance.
2026-10-08 15:45:34 -07:00
bflatasticandClaude Opus 5.5 aa5e8f46e9 Part of #579: vectorcraft-cli lists the fonts font services load too
The DirectWrite font lister (#594) was installed in the desktop app only:
`vectorcraft-cli` (convert, run, MCP) didn't see fonts that font services
such as Adobe Fonts load on Windows, so its exports set such type in a
fallback font.

- The CLI shares the app's `system_fonts.rs` and installs it first thing
  on Windows (not the win7 build). The text crate stays free of unsafe
  code; the CLI denies unsafe code instead of forbidding it, so the
  shared module's own allowance applies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 05:43:13 +07:00
bflatastic 9dec49ed03 Merge #621: Fix #585: a two-finger tap undoes, a three-finger tap redoes
# Conflicts:
#	ROADMAP.md
2026-10-09 05:42:35 +07:00
BFlat dc157c6c5b Merge pull request: land #619 (fix #598)
Land #619 (fix #598: the Free Transform widget)
2026-10-09 05:41:12 +07:00
bflatastic 22008b4e68 Merge #619: Fix #598: the Free Transform widget
# Conflicts:
#	ROADMAP.md
#	crates/ui-egui/src/i18n/cs.tsv
#	crates/ui-egui/src/i18n/es.tsv
#	crates/ui-egui/src/i18n/it.tsv
#	crates/ui-egui/src/i18n/ja.tsv
#	crates/ui-egui/src/i18n/pt-br.tsv
#	crates/ui-egui/src/i18n/ru.tsv
#	crates/ui-egui/src/i18n/zh-hans.tsv
#	crates/ui-egui/src/i18n/zh-hant.tsv
2026-10-09 05:36:53 +07:00
bflatasticandClaude Opus 5.5 2497713779 Fix #598: the Free Transform widget
While the Free Transform tool (E) is active, a small box at the canvas's
top left holds Constrain over the tool's three modes: Free Transform,
Perspective Distort and Free Distort. Before, the modes were reachable
only by modifier keys or `tool.setOption`.

- The buttons set the tool's `constrain` and `mode` options through
  `tool.setOption`, as agents do. Constrain acts as Shift held and is
  greyed in the distort modes, where it does nothing. The modifier keys
  keep working while dragging.
- Theme tokens; Constrain shows the link icon, Free Transform the tool's
  own icon, the distort modes are drawn in code. Tooltips translated
  (cs, es, it, ja, pt-br, ru, zh-hans, zh-hant).
- `ui.inspect` reports where it shows (`freeTransformWidget`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 05:31:43 +07:00
dotCooCoo 67af21c6db Contributors: add dotCooCoo (Robert Lee) 2026-10-08 15:18:57 -07:00
dotCooCoo e0b1f55250 Swatches: swatch exchange (.ase) files open as swatch libraries
File › Open, Other Library… and the User Defined library folder read
.ase files. Colors keep their model, global and spot colors stay global
and spot, and color groups stay groups. The library readers take a
file's bytes. On the web, File › Open passes a swatch library file to
swatch.library.load as dataBase64 instead of as lossy UTF-8 text.

Swatch and group names in a library are now unique, in .gpl files too.
Add to Swatches puts each color group of a library into one document
group when the document already has a swatch with the group's name.
The library fuzz helpers fail when a command panics.
2026-10-08 15:18:57 -07:00
bflatasticandClaude Opus 5.5 1d013959cb Fix #530: Edit Artboards adds, deletes and leaves artboards from Properties
The Properties panel's Edit Artboards turned on the Artboard tool but the
panel went on showing the document's sections, with no way there to add
an artboard (only dragging on the canvas, which the hint bar names, or
the Artboards panel's button). While the Artboard tool is on, the panel
now shows the active artboard: its name and X, Y, W and H (editable,
artboard.setProps), New Artboard (which becomes the active one), Delete
Artboard (with more than one), and Exit back to the Selection tool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 02:29:26 +07:00
bflatasticandClaude Opus 5.5 b1f7d5ad7d Fix #563: the font menu's live preview no longer flickers
Hovering a family in a font menu previews it on the selected text. The
menu took its caller's font as the current family, and the caller reads
it from the text, which the preview had just changed: the next frame the
hovered family looked like the current one, so the menu ended the
preview, and the frame after it previewed the family again. The text
switched fonts every frame (and the document's revision ran up with it).

The menu now keeps the family the text had as it opened as the current
one: a preview stays on the text until another row is hovered, the
current family is hovered again, or the menu closes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 02:20:21 +07:00
callmeBallochandClaude Opus 5.5 2e55ce6d5b AF.3: open .afphoto by content without listing it for Finder
The macOS bundle must declare every File › Open extension, and the
packaging of a vector app must not mention raster-editor files. Affinity
Photo documents still open and place: the reader recognizes them by
their content, and a test checks it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
2026-10-08 19:13:50 +00:00
callmeBalloch 2b4d8f4074 Merge remote-tracking branch 'upstream/main' into claude/kind-darwin-btjse2
# Conflicts:
#	README.md
#	ROADMAP.md
#	apps/vectorcraft/src/open_documents.rs
2026-10-08 18:45:39 +00:00
callmeBallochandClaude Opus 5.5 297a01b6aa AF.3: native Affinity import with artboards, vectors, text and images
Open and Place read Affinity documents natively (.af from Affinity 3,
.afdesign/.afphoto/.afpub from Affinity 1 and 2): layers and sublayers,
groups, artboards with their names and backgrounds, Publisher pages side
by side, curves with live corners, parametric shapes, compound shapes,
clipping by nesting, vector and pixel masks, solid and gradient fills,
strokes, fill layers, artistic and frame text, placed images (their
original JPEG/PNG), pixel layers, embedded documents as their cached
picture, opacity, visibility, locking and blend modes, at the document's
resolution in points. Unsupported content (effects, adjustments, brushes,
master pages, special shapes, Affinity-only blend modes...) is listed in
the import warning, one line per kind. A file whose native data can't be
read opens as its embedded preview with the reason, and that preview
cannot be placed or used as a template or library. Save never writes back
over the Affinity file and there is no Affinity export.

The document semantics were fitted to the thumbnails Affinity embeds in
176 public documents. `cargo xtask corpus --affinity` fetches 22 public
CC0/MIT/Apache-2.0 documents at pinned commits, sha256-verified; the new
engine test renders each and compares it with Affinity's own thumbnail
(0-4.4 of 255 mean difference, per-file ceilings). import_fuzz mutates a
synthetic native document's stream and container through open, render
and export; the fuzz crate gains a stream target.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
2026-10-08 17:58:11 +00:00
callmeBallochandClaude Opus 5.5 1c47480a09 AF.2: bounded Affinity archive and object-stream reader
Read the native container beyond its preview: the chain of allocation
tables (#FAT, #FT2, #FT3, #FT4) with newest-revision selection, CRC-checked
extraction of stored, zlib and zstd entries with their byte predictors,
and the self-describing tagged object stream in doc.dat as a generic tree
of shared, inline and positional objects.

Every declared size is checked against per-entry and per-import budgets
before allocation; the zstd window is capped at 64 MiB; table chains are
cycle-checked; arrays are checked against the remaining bytes; object
nesting stops at 384 levels.

The header now accepts container versions 8 to 12 and the flag values 4
and 8 that real Affinity 3 files carry (the previous check rejected the
public CC0 Affinity 3 documents); the thumbnail record is the same in all
those versions. Checked against 176 public documents saved by Affinity 1.x
to 3.x: every doc.dat decompresses with a matching CRC and parses with no
bytes left over.

A test-only `synth` feature builds synthetic containers for unit tests
and fuzz seeds; it is not an Affinity exporter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
2026-10-08 17:09:56 +00:00
callmeBalloch 3e782b22a9 Merge remote-tracking branch 'upstream/main' into claude/kind-darwin-btjse2
# Conflicts:
#	ROADMAP.md
2026-10-08 16:08:36 +00:00
callmeBalloch 3c7bdfe6a3 AF.1: import bounded Affinity previews with explicit fidelity warnings
Read only the indexed PNG thumbnail observed in version-12 Affinity containers. Bound and validate the PNG before decoding, warn about omitted native content, clear the source save target, and reject auxiliary imports that cannot display that warning. Add malformed-input, lifecycle, property and optional native-oracle tests plus a fuzz target. Editable native layers and Affinity export remain unsupported.
2026-10-08 17:19:18 +02:00
callmeBalloch c13e4133e0 CI.1: make baseline checks portable across toolchains and hosts 2026-10-08 17:19:04 +02:00
207 changed files with 19033 additions and 791 deletions

No files matched your search

Generated
+30
View File
@@ -3460,6 +3460,15 @@ dependencies = [
"unicode-script",
]
[[package]]
name = "ruzstd"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a252f5e20f038fe7b4ea53e073e65398d652c864cc162fc77c56c2f13717b888"
dependencies = [
"twox-hash",
]
[[package]]
name = "ryu"
version = "1.0.23"
@@ -4123,6 +4132,12 @@ dependencies = [
"core_maths",
]
[[package]]
name = "twox-hash"
version = "2.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86a801b3cea342a06d468c8710662aa29e5e05e4f5c0d62f00bbb7f2ad7941c2"
[[package]]
name = "type-map"
version = "0.5.1"
@@ -4320,6 +4335,16 @@ dependencies = [
"winresource",
]
[[package]]
name = "vectorcraft-affinity"
version = "0.7.0"
dependencies = [
"crc32fast",
"flate2",
"proptest",
"ruzstd",
]
[[package]]
name = "vectorcraft-brush"
version = "0.7.0"
@@ -4352,6 +4377,7 @@ dependencies = [
name = "vectorcraft-cli"
version = "0.7.0"
dependencies = [
"log",
"serde_json",
"vectorcraft-color",
"vectorcraft-doc",
@@ -4362,6 +4388,8 @@ dependencies = [
"vectorcraft-render",
"vectorcraft-svg",
"vectorcraft-testkit",
"vectorcraft-text",
"windows",
]
[[package]]
@@ -4411,6 +4439,7 @@ dependencies = [
name = "vectorcraft-engine"
version = "0.7.0"
dependencies = [
"crc32fast",
"flate2",
"image",
"kurbo 0.13.1",
@@ -4419,6 +4448,7 @@ dependencies = [
"serde",
"serde_json",
"thiserror 2.0.21",
"vectorcraft-affinity",
"vectorcraft-brush",
"vectorcraft-cad",
"vectorcraft-color",
+2
View File
@@ -33,6 +33,7 @@ vectorcraft-cad = { path = "crates/cad" }
vectorcraft-eps = { path = "crates/eps" }
vectorcraft-metafile = { path = "crates/metafile" }
vectorcraft-plugins = { path = "crates/plugins" }
vectorcraft-affinity = { path = "crates/affinity" }
# foundation
kurbo = { version = "0.13", features = ["serde"] }
@@ -42,6 +43,7 @@ serde = { version = "1", features = ["derive", "rc"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
log = "0.4"
proptest = "1"
crc32fast = "1.5"
# tests: a TCP port bound without listening (connections to it are refused)
socket2 = "0.6"
# Sandboxed WebAssembly plug-ins: a pure-Rust interpreter (MIT OR Apache-2.0) that builds for wasm32
+8 -2
View File
@@ -193,8 +193,14 @@ milestones, and honest time-to-parity estimates.
**Where we are (2026-10-07):** roughly 69–75% of Illustrator's features exist and work, and about 40–55% of
"a power user can't tell the difference". Everyday vector illustration is close to usable: drawing and path tools,
Pathfinder and Shape Builder, paint, gradients, appearance and transparency, type with styles, threading and Hebrew/Arabic bidirectional layout, and
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, EPS, DXF, EMF/WMF, raster formats and PSD, Print, Package). The interface
speaks English, Japanese, Traditional and Simplified Chinese, Spanish, Italian and Russian (and Czech and Brazilian Portuguese in the menus).
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, EPS, DXF, EMF/WMF, raster formats and PSD, Print, Package).
Affinity documents (`.af` from Affinity 3, `.afdesign`, `.afpub` and, by their content, `.afphoto` from Affinity 1 and 2) open
and place natively: layers, groups, artboards and pages, curves and shapes, fills, gradients and strokes, clipping
and masks, text and images, with what didn't come in (effects, adjustments, brushes, master pages…) listed in the
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).
The scores are
self-assessed, so the [honest assessment](ROADMAP.md#honest-assessment-2026-10-05) explains how far to trust them.
+37 -31
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
@@ -34,11 +34,11 @@ since 2026-10-01.
| Dimension | Estimate | Evidence and what's missing |
|---|---|---|
| **Breadth:** menus, tools and panels exist | ~90% | 13 menu items still stubbed; every tool implemented except Touch Type; 51 panel modules |
| **Depth:** each feature behaves like Illustrator | ~69–75% | Strong: paint, appearance and colour (M3), Pathfinder and booleans, selection, drawing, files (M4). Weak: advanced type (~45%), brushes and symbols (in progress), raster effects (~20%) |
| **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~1 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 |
| **Depth:** each feature behaves like Illustrator | ~69–75% | Strong: paint, appearance and colour (M3), Pathfinder and booleans, selection, drawing, files (M4). Weak: advanced type (~45%; inline symbols in text are beyond parity and don't raise it), brushes and symbols (in progress), raster effects (~20%) |
| **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. 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. Native `.ai` private data is out of scope by design; 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 |
@@ -53,9 +53,12 @@ Ordered by how much each gap blocks someone from switching. Sizes are one-agent
1. **Interaction fidelity:** go tool by tool and panel by panel against `plan/illustrator/05-tools.md`,
`06-panels.md` and `09-shortcuts.md`, covering modifier keys, cursors, the Properties panel per context and
isolation mode. The Shaper’s rough-square recognition and shape orientation steps are corrected; Shaper Groups now retain editable originals for region merges/punches and separate fill/stroke edits; their construction-mode widget and gesture refinements remain. 60–90 h.
2. **Photoshop-style raster effects and the Effect Gallery:** about 55 filters (Artistic, Brush Strokes, Distort,
Pixelate, Sketch, Stylize, Texture, Video) on the raster pipeline that drop shadows already use, applied at
Document Raster Effects Settings resolution. Parallelizes well across agents. 28–42 h.
2. **Photoshop-style raster effects and the Effect Gallery:** about 52 filters (Artistic, Brush Strokes, Distort,
Pixelate, Sketch, Stylize, Texture, Video) and the Effect Gallery. The pixel-filter pipeline is in place
(`vectorcraft_effects::pixel`: the object rendered offscreen, filtered in stack order with Gaussian Blur and
Feather, cached while panning, images in PDF/SVG/Expand Appearance) with Blur › Radial Blur and Smart Blur
(Normal mode only) and Sharpen › Unsharp Mask; each new filter is now one function, a catalogue row, menu and
translations. Parallelizes well across agents. 26–40 h.
3. **3D and Materials:** Extrude & Bevel, Revolve, Inflate and Rotate with lighting and materials, using a software
renderer in its own crate (layering allows it below L6), with output in SVG/PDF as rasters or projected vectors.
The largest single gap. 50–80 h.
@@ -70,7 +73,9 @@ Ordered by how much each gap blocks someone from switching. Sizes are one-agent
6. **Automation:** Variables (data merge), a scripting surface over the command registry, batch. 8–12 h.
7. **Views and windows:** multiple windows and arrange, Consolidate All Windows, video
rulers. 15–25 h.
8. **Hardening at scale:** a corpus of real-world SVG/PDF/EPS files, idle-machine perf budgets, Windows, Linux and
8. **Hardening at scale:** a corpus of real-world SVG/PDF/EPS files, Affinity files from more versions and platforms
(the native reader is checked against 22 public files' own thumbnails; synthetic one-feature oracles saved by
Affinity itself are still missing), idle-machine perf budgets, Windows, Linux and
browser QA, pen tablets on Wayland and pen pressure beyond Windows (winit 0.31's tablet input, #491), accessibility, packaging. 50–80 h.
### Where we're going
@@ -102,14 +107,14 @@ 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, 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 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).
- 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).
- A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, Remove Anchor Points when anchors are selected, masks, compound paths, guides, Transform, Arrange, Select, Export Selection), or the view commands on empty canvas.
- Save / Don't Save / Cancel before closing or quitting with unsaved documents (tabs, Close, Close All, Quit, window close).
- Four brightness themes, persistent preferences.
- Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap), then Construction Guides and their Angles (#506: a drawn point near a line at one of those angles through the point it leaves from lands on it). Then Type › Type Object Selection by Path Only (type is picked on the baseline of any of its lines, area type's frame or the path of type on a path, no longer anywhere in its bounds; the Type tools and the Eyedropper still take a click among the characters). Then General › Anti-aliased Artwork (off, the canvas draws the art with hard edges). Then Guides & Grid › Show Pixel Grid (Above 600% Zoom), a line at every document pixel over the art in Pixel Preview, turning with a rotated view as the document grid now does too. Then General › Show The Home Screen When No Documents Are Open (off, closing the last document leaves an empty window; the Home button still opens the screen). Then Units › Numbers Without Units Are Points (on by default: a number typed with no unit into a field in picas is read in points, a typed unit still wins; dimmed unless a unit is Picas). Then User Interface › Auto-Collapse Iconic Panels (off by default: on, a click away from a panel popped out of the icon column puts it away). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). About 30 stored options still do nothing yet (the rest of #394: Auto Size New Area Type, Show Font Names in English, Highlight Alternate Glyphs, Spacing Guides, performance, devices…).
- Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap), then Construction Guides and their Angles (#506: a drawn point near a line at one of those angles through the point it leaves from lands on it). Then Type › Type Object Selection by Path Only (type is picked on the baseline of any of its lines, area type's frame or the path of type on a path, no longer anywhere in its bounds; the Type tools and the Eyedropper still take a click among the characters). Then General › Anti-aliased Artwork (off, the canvas draws the art with hard edges). Then Guides & Grid › Show Pixel Grid (Above 600% Zoom), a line at every document pixel over the art in Pixel Preview, turning with a rotated view as the document grid now does too. Then General › Show The Home Screen When No Documents Are Open (off, closing the last document leaves an empty window; the Home button still opens the screen). Then Units › Numbers Without Units Are Points (on by default: a number typed with no unit into a field in picas is read in points, a typed unit still wins; dimmed unless a unit is Picas). Then User Interface › Auto-Collapse Iconic Panels (off by default: on, a click away from a panel popped out of the icon column puts it away). Then Performance › Animated Zoom (on, with GPU Performance, which it needs and which dims it when off: the Zoom tool dragged sideways zooms about the press, right in and left out, and held still zooms on, Alt out; off, a drag zooms to the area dragged across, now drawn as a marquee), User Interface › Large Tabs (taller document tabs) and Scale Cursor Proportional to UI (off, the tool cursors keep their size whatever the UI Scaling), and File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview (off by default: Pixel Preview draws images with each pixel taking its nearest image pixel). Type › Auto Size New Area Type works too (new area type gets Auto Size). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). Then Smart Guides › Spacing Guides (moves space the selection evenly with its row or column, see Drawing aids). About 20 stored options still do nothing yet (the rest of #394: Show Font Names in English, Highlight Alternate Glyphs, Display Print Size at 100% Zoom, Identify Objects By, East Asian Type units, Open Documents As Tabs, Real-time Drawing and Editing, GPU Performance beyond Animated Zoom, hyphenation, scratch disks, the legacy clipboard, Appearance of Black, Touch Workspace).
- Dialogs move by dragging their heading (the band down from the top edge, as a title bar), stay on screen and keep where they were moved for the session; the form dialogs' choices are dropdowns (Offset Path's Joins, Average's and Shear's and Reflect's axis), and Offset Path lists Offset, Joins, Miter Limit and previews (#423).
- Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). Number fields in panels, the Control bar and dialogs (preferences too) step with Up/Down, or with the mouse wheel over the focused field (#485: a notch a step; the panel under it then stays put, and an unfocused field leaves the wheel to scrolling) (Shift: ten, Ctrl/Cmd: a tenth of their unit), applied at once.
- Scrubby labels (#400, beyond Illustrator, as in Photoshop): dragging a numeric field's label left or right (the ↔ cursor shows over it) steps the value one unit every 2 points (Shift: ten, Ctrl/Cmd: a tenth; counts stay whole), in the field's unit and within its limits, applied live as the art follows; the whole drag is one undo step and Escape puts the value back. A click on the label focuses the field; the text box itself never scrubs, and a field with no label doesn't. Text labels before a field (Transform, Properties, Control bar, Stroke, Transparency, form rows in dialogs) and the Character and Paragraph panels' icons act as labels. Preferences › General › Scrub Numeric Fields by Dragging (on by default) turns it off.
@@ -129,24 +134,24 @@ 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), 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), 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.
- **Tool double-clicks:** double-clicking a tool's button opens its options: Hand fits the artboard in the window, Zoom shows 100 %, Rotate, Scale, Reflect and Shear open their Object › Transform dialogs, the Selection, Direct Selection and Group Selection tools the Move dialog (Enter opens these seven tools' dialogs too), Gradient, Eyedropper, Blend and the Liquify tools their panels or options, and the Pencil, Paintbrush, Smooth, Blob Brush and Eraser tools their Tool Options (Fidelity, fill, the tolerances, the brush size).
- **Layers panel:** every layer, sublayer, group and object is a clickable row at any depth: a click highlights it and makes its layer current (new art goes there), Shift-click a range, Ctrl/Cmd-click toggles. Sublayers are no longer treated as objects: clicks, marquees, Select All and the Select menu take the art inside them, and each sublayer selects in its own colour. Selection squares on rows with selected art (click selects a row's art, Shift adds or removes; dragging the square moves the selection to another row, Alt copies), the eye and lock on every row with drag-down-the-column (one undo step), Ctrl/Cmd-click an eye for per-layer Outline, Alt-click an eye or lock to hide or lock the other layers (again: show or unlock them all), triangles with Alt to open or close everything inside, drag and drop rows above, below or into layers and groups with a drop line (Alt copies) and a dimmed copy of the row under the pointer (dropped on the trash rows are deleted, on New Layer duplicated, as in Illustrator), inline rename, Delete key on highlighted rows. Buttons and the full panel menu: New Layer/Sublayer (Alt: options, Ctrl: on top), Duplicate, Delete, Options for Selection, clipping mask, isolation, Locate Object, Merge Selected, Flatten Artwork, Collect in New Layer, Release to Layers (Sequence and Build), Reverse Order, Template, Hide/Outline/Lock Others and Show/Preview/Unlock All, Paste Remembers Layers, Panel Options (row size, thumbnails per kind, Show Layers Only). Layer Options: name, colour (presets, each with a chip of its colour, or custom), Template, Lock, Show, Print, Preview (outline drawing and clicking) and Dim Images to %. Every operation is a command (`layer.move`, `layer.highlight`, `layer.merge`…) with validation against cycles and invalid nesting.
- **Drawing aids:** Smart Guides and snapping (moving, with the Selection, Group Selection or Direct Selection tool, and resizing by the bounding-box handles with the Selection or Free Transform tool, Shift and Alt too: the dragged corner or side lands on the other objects' anchors and centres ("anchor", "center") or lines up with their edges and centres (a line and "align"); the Rotate, Scale, Shear and Reflect tools drag from the anchor they grab and snap the pointer the same way, so a corner dragged onto another object's anchor scales exactly onto it, #482; anchors dragged with Direct Selection: the one pressed on lands on other anchors, its own path's that stay put among them, on path segments or in line with them, the others following, Shift keeps 45° steps, and with Smart Guides off Snap to Point pulls it onto anchors; the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving, copying and resizing them, and the Transform panel shows and edits the active artboard's X/Y/W/H; drawn points, #506: hovering shows where the press goes, the point lands on the other objects' anchors, centres and paths ("anchor", "center", "path") or lines up with their anchors, edges and centres ("align"), the Pen's on the path being drawn too, near a construction guide through the anchor before it on that guide (Preferences › Smart Guides › Construction Guides and Angles), and with Shift kept to its 45° step or a square's diagonal it slides along that way into line; the targets are gathered once per document state, from the art in the window, and the anchors, centres and lines near the pointer are found by bisection, so hovering never rescans the document), guides dragged out of the rulers (#451: they snap as moved guides do, Shift to the ruler's ticks; with the Artboard tool they are artboard guides, which run across their artboard only and move, copy and delete with it, `guide.add {artboard}`) that the Selection, Direct Selection and Group Selection tools then pick and drag (#414: the selected guides highlight and move together, Alt copies them, Shift snaps the dragged one to the ticks, otherwise it snaps to pixels, the grid or the edges, centres (so the sides' midpoints) and anchors of the art and the artboards, or with Smart Guides off Snap to Point pulls it onto anchors; the arrow keys nudge them, Delete deletes them, dragging one back onto its ruler deletes it, Lock Guides and Hide Guides leave them alone; `guide.list`, `guide.select`, `guide.move` and `guide.remove` reach agents), a right-click on either ruler or the origin box to pick the document's units (the current one checked; `document.setUnits`, one undo step, #528), and Draw Normal / Behind / Inside modes.
- **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard and fits it, and Fit Artboard in Window and Actual Size show the navigator's artboard. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it. Duplicate Artboards, or a row dragged onto New Artboard, copies the artboard with its art (#446). Escape leaves Presentation Mode, F from it goes back to Normal Screen Mode, and the screen mode isn't saved with the preferences: the app always starts in Normal Screen Mode with its menus and panels (#472).
- **Layers panel:** every layer, sublayer, group and object is a clickable row at any depth: a click highlights it and makes its layer current (new art goes there), Shift-click a range, Ctrl/Cmd-click toggles. Sublayers are no longer treated as objects: clicks, marquees, Select All and the Select menu take the art inside them, and each sublayer selects in its own colour. Selection squares on rows with selected art (click selects a row's art, Shift adds or removes; dragging the square moves the selection to another row, Alt copies), the eye and lock on every row with drag-down-the-column (one undo step), Ctrl/Cmd-click an eye for per-layer Outline, Alt-click an eye or lock to hide or lock the other layers (again: show or unlock them all), triangles with Alt to open or close everything inside, drag and drop rows above, below or into layers and groups with a drop line (Alt copies) and a dimmed copy of the row under the pointer (dropped on the trash rows are deleted, on New Layer duplicated, as in Illustrator), inline rename, Delete key on highlighted rows. Buttons and the full panel menu: New Layer/Sublayer (Alt: options, Ctrl: on top), Duplicate, Delete, Options for Selection, clipping mask, isolation, Locate Object, Merge Selected, Flatten Artwork, Collect in New Layer, Release to Layers (Sequence and Build), Reverse Order, Template, Hide/Outline/Lock Others and Show/Preview/Unlock All, Paste Remembers Layers, Panel Options (row size, thumbnails per kind, Show Layers Only). Layer Options: name, colour (presets, each with a chip of its colour, or custom), Template, Lock, Show, Print, Preview (outline drawing and clicking) and Dim Images to %. Every operation is a command (`layer.move`, `layer.highlight`, `layer.merge`…) with validation against cycles and invalid nesting. A document opens with only its top-level layers open; the rows left open are saved in the native file (nothing is written for the default) and reopen that way. Only the rows scrolled into view are laid out, from a row list made once per change: a layer of 5,000 groups of 10 paths, all open, draws the panel in 0.4 ms a frame instead of about 29 s (#638).
- **Drawing aids:** Smart Guides and snapping (moving, with the Selection, Group Selection or Direct Selection tool, and resizing by the bounding-box handles with the Selection or Free Transform tool, Shift and Alt too: the dragged corner or side lands on the other objects' anchors and centres ("anchor", "center") or lines up with their edges and centres (a line and "align"); with Smart Guides › Spacing Guides, a moved selection that lines up with nothing along an axis lands as far from its neighbour as two other objects of its row or column are apart, or halfway between its two neighbours, the equal gaps marked; the Rotate, Scale, Shear and Reflect tools drag from the anchor they grab and snap the pointer the same way, so a corner dragged onto another object's anchor scales exactly onto it, #482; anchors dragged with Direct Selection: the one pressed on lands on other anchors, its own path's that stay put among them, on path segments or in line with them, the others following, Shift keeps 45° steps, and with Smart Guides off Snap to Point pulls it onto anchors; the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving, copying and resizing them, and the Transform panel shows and edits the active artboard's X/Y/W/H; drawn points, #506: hovering shows where the press goes, the point lands on the other objects' anchors, centres and paths ("anchor", "center", "path") or lines up with their anchors, edges and centres ("align"), the Pen's on the path being drawn too, near a construction guide through the anchor before it on that guide (Preferences › Smart Guides › Construction Guides and Angles), and with Shift kept to its 45° step or a square's diagonal it slides along that way into line; the targets are gathered once per document state, from the art in the window, and the anchors, centres and lines near the pointer are found by bisection, so hovering never rescans the document), guides dragged out of the rulers (#451: they snap as moved guides do, Shift to the ruler's ticks; with the Artboard tool they are artboard guides, which run across their artboard only and move, copy and delete with it, `guide.add {artboard}`) that the Selection, Direct Selection and Group Selection tools then pick and drag (#414: the selected guides highlight and move together, Alt copies them, Shift snaps the dragged one to the ticks, otherwise it snaps to pixels, the grid or the edges, centres (so the sides' midpoints) and anchors of the art and the artboards, or with Smart Guides off Snap to Point pulls it onto anchors; the arrow keys nudge them, Delete deletes them, dragging one back onto its ruler deletes it, Lock Guides and Hide Guides leave them alone; `guide.list`, `guide.select`, `guide.move` and `guide.remove` reach agents), a right-click on either ruler or the origin box to pick the document's units (the current one checked; `document.setUnits`, one undo step, #528), and Draw Normal / Behind / Inside modes.
- **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard and fits it, and Fit Artboard in Window and Actual Size show the navigator's artboard. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it. Duplicate Artboards, or a row dragged onto New Artboard, copies the artboard with its art (#446). Escape leaves Presentation Mode, F from it goes back to Normal Screen Mode, and the screen mode isn't saved with the preferences: the app always starts in Normal Screen Mode with its menus and panels (#472). View › Pixel Preview shows the art as the document pixels it rasterizes to, one per point and hard-edged when magnified, turning with a rotated view (it used to draw the smooth vectors, with only the pixel grid over them).
- **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. 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), 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, 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, 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 or the em box top/right, centre or bottom/left (ICF top and bottom not yet); 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).
- **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.
- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` and PDF soft masks.
- **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 and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), the Image Trace panel and Expand, plus Release in Properties; the View modes are not done), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
- **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 and loaded as `.vcswatches`, `.gpl` or CSS. 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.
- **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel follows each colour's own model and Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers (the Properties panel has the Control bar's Fill and Stroke chips, Stroke link and weight spinner, with nothing selected too), panels have Illustrator's Window menu keys (Align Shift+F7, Transform Shift+F8, Pathfinder Cmd+Shift+F9, Layers F7, Character Cmd+T…; every panel can be given one in Edit › Keyboard Shortcuts), and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. Document Color Mode converts the art, symbols, pattern tiles and swatches through the colour settings: Gray stays Gray, RGB greys separate into four-colour greys and a rich black as Illustrator does, or go on the black plate only with `grays: "black"` (also for `document.open {colorMode}`).
- **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.
- **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel follows each colour's own model and Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers (the Properties panel has the Control bar's Fill and Stroke chips, Stroke link and weight spinner, with nothing selected too), panels have Illustrator's Window menu keys (Align Shift+F7, Transform Shift+F8, Pathfinder Cmd+Shift+F9, Layers F7, Character Cmd+T…; every panel can be given one in Edit › Keyboard Shortcuts), and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). An ICC CMYK profile converts straight into the RGB working space (not through sRGB, so a wider space such as Wide Gamut RGB keeps colours sRGB can't hold) with the colour settings' black-point compensation, on the canvas and in RGB exports alike. Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. Document Color Mode converts the art, symbols, pattern tiles and swatches through the colour settings: Gray stays Gray, RGB greys separate into four-colour greys and a rich black as Illustrator does, or go on the black plate only with `grays: "black"` (also for `document.open {colorMode}`).
- **Gradients:** lossless, validated gradient params; gradients follow rotate, reflect, shear, non-uniform scale and distortions; the Gradient tool edits the fill or stroke of the active proxy, type included; an interactive on-canvas annotator (move, length/angle, rotate, add/move/delete/duplicate stops, midpoints, a stop popover, Delete and arrow keys). The Gradient panel has the Fill/Stroke proxy, a gradient-swatch dropdown with Save to Swatches, midpoint locations, Alt-drag to copy or swap stops, swatch drops on the ramp and a stop eyedropper; copied appearances and graphic styles place gradients on each target's own bounds; gradient handles snap and honour Constrain Angle. Gradient stops stay linked to global and spot swatches. Freeform gradients (points and lines) are edited on the canvas; radial gradients have an extent ellipse, an aspect handle and an off-centre focal point (SVG `fx`/`fy`, PDF two-point radials). A stroke's gradient runs within, along or across it. Object ▸ Expand turns gradients into N objects or a gradient mesh.
- **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance.
- **Appearance:** the Appearance panel's selected row drives every paint, stroke, gradient and transparency edit (`item` on the commands); effects apply to one fill or stroke; per-item Opacity popups, whose blend-mode list and opacity slider stay open inside them (#430; the Control bar's Stroke popover too); Mixed Appearances, Layers target dots and basic-appearance rules. Effects can be reordered, moved between items and copied by dragging; Show All Hidden Attributes; clicking an effect edits it in place; the object thumbnail drags onto art; effects render on type, images, symbols, blends, envelopes, meshes and repeats, and per-item raster effects export. Groups, layers and type have their own fills, strokes and effects (Contents and Characters rows); the Layers panel's target circles target layers, groups and objects; Expand Appearance turns fills, strokes, effects and brushes into objects.
@@ -158,8 +163,9 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed comes in as live type shown in the fallback font until the font is installed (exports that draw it — PDF, EPS, EMF/WMF, raster, SVG outlines or embedded fonts — warn that they wrote the fallback font; `textAs: outlines` keeps the PDF's embedded glyphs).
- **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); 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).
- 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).
- 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). 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).
- **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped.
@@ -174,10 +180,10 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
| 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 |
| 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. Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, polish | 3–6 |
| 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, 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, 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 |
@@ -210,20 +216,20 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| Strokes, brushes, width profiles | 5 | 88% | brush options depth, brush libraries (generated in code), brushes driven by pen tilt, bearing and barrel rotation (6D art pens) | 12–20 |
| Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 |
| Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap, SVG Filters | 6–10 |
| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 20% | ~55 Photoshop-style filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Stylize, Texture, Video) and the Effect Gallery; Document Raster Effects Settings and raster effects in PDF are done | 28–42 |
| 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, Character Alignment on the ICF top and bottom), 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 | 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), 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 |
| 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), Image Trace View modes (Outlines, Source Image) (Vector Halftone is 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 presets/rearrange polish (Document Setup, New Document and the rest of the Layers panel are done) | 4–7 |
| 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 | 84% | global/video rulers, Spacing Guides, 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) | 2–4 |
| File formats | 6 | 85% | 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 and presets are done | 6–10 |
| 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) |
| 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 30 Preferences options stored but not yet acted on (#394: Spacing Guides, Auto Size New Area Type, Show Font Names in English, Highlight Alternate Glyphs, performance, devices…) | 14–22 |
| 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 |
| **Feature parity** | **103** | **~75%** | | **~210–330** |
| Interaction-fidelity pass (side by side with Illustrator: every tool modifier, cursor, dialog, Properties context) | | | | 60–90 |
@@ -236,7 +242,7 @@ With 4–6 agents working on disjoint crates (as the layering allows) the wall-c
_Inventories (2026-10-07):_ 13 menu items still stubbed (`todo(…)` in `crates/ui-egui/src/menus.rs`); every tool
implemented except Touch Type; 51 panel modules; Illustrator-style live
effects ~44/54, Photoshop-style raster effects ~1/56, 3D 0/5; ~2,840 tests; ~221k lines of Rust.
effects ~44/54, Photoshop-style raster effects ~4/56, 3D 0/5; ~2,840 tests; ~221k lines of Rust.
_Where we already beat Illustrator:_ exact curve booleans, off-thread multithreaded rendering, undo that never runs out,
lossless SVG/PDF export of live effects with SVG filters, a documented JSON format, the same app on the web, and every
+7
View File
@@ -22,7 +22,14 @@ vectorcraft-geom = { workspace = true }
vectorcraft-svg = { workspace = true }
vectorcraft-mcp = { workspace = true }
vectorcraft-render = { workspace = true }
vectorcraft-text = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
# DirectWrite's system font collection (the desktop app's src/system_fonts.rs, shared): fonts that
# font services such as Adobe Fonts load in place (#579). MIT OR Apache-2.0.
[target.'cfg(all(windows, not(target_vendor = "win7")))'.dependencies]
windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite"] }
[dev-dependencies]
vectorcraft-testkit = { workspace = true }
+10 -1
View File
@@ -9,7 +9,9 @@
//! vectorcraft-cli bench FILE [--size 2880x1800] [--iters 5]
//! vectorcraft-cli perf [--paths 50000]
//! ```
#![forbid(unsafe_code)]
// Denied, not forbidden: the DirectWrite font lister shared with the desktop app (Windows) allows
// it for its COM calls, and nothing else may.
#![deny(unsafe_code)]
use std::io::Write;
use std::process::ExitCode;
@@ -35,6 +37,10 @@ macro_rules! out {
}
mod perf;
/// The desktop app's DirectWrite font lister, shared: exports and MCP find the same fonts.
#[cfg(all(windows, not(target_vendor = "win7")))]
#[path = "../../vectorcraft/src/system_fonts.rs"]
mod system_fonts;
use serde_json::{Value, json};
use vectorcraft_mcp::{Backend, DEFAULT_ADDR, Headless, Remote, Server};
@@ -93,6 +99,9 @@ fn usage() -> String {
}
fn main() -> ExitCode {
// Before any font lookup: the fonts font services load (#579).
#[cfg(all(windows, not(target_vendor = "win7")))]
system_fonts::install();
let args: Vec<String> = std::env::args().skip(1).collect();
let r = match args.first().map(String::as_str) {
Some("mcp") => mcp(&args[1..]),
+38 -9
View File
@@ -22,8 +22,9 @@ use std::sync::{Arc, OnceLock};
use eframe::egui_wgpu::NativeAdapterSelectorMethod;
use eframe::wgpu::{self, Backend, DeviceType, PowerPreference};
/// The adapters a restart leaves out, those the window failed on, as comma-separated
/// `backend:vendor:device` keys (`Vulkan:1002:164e`). Set by the app itself when it starts again.
/// The adapters a restart leaves out, those the window failed on, as comma-separated [`key`]s
/// (`Vulkan:1002:164e`, `Metal:Intel Iris Pro Graphics`). Set by the app itself when it starts
/// again.
pub const SKIP_ENV: &str = "VECTORCRAFT_GPU_SKIP";
/// wgpu's variable for choosing an adapter by (part of) its name, any case.
@@ -36,7 +37,7 @@ const HELP: &str =
/// Frames the UI ran before the failure, below which it was still starting up. The failure in
/// #502 came on the first or second frame; later, the adapter has proved itself, and a lost
/// window is something else (such as the compositor restarting).
const STARTUP_FRAMES: u64 = 10;
pub const STARTUP_FRAMES: u64 = 10;
/// The preference used when Preferences say Automatic. Windows and macOS show frames from any GPU
/// (the integrated one avoids the flicker of #306). Elsewhere, the system's own order: Mesa's
@@ -60,7 +61,7 @@ pub fn power_preference(pref: Option<&str>, env: Option<PowerPreference>) -> Pow
/// One adapter, as [`adapter_order`] sees it.
#[derive(Clone, Debug)]
pub struct Candidate {
/// Identifies the adapter across a restart: `backend:vendor:device` ([`key`]).
/// Identifies the adapter across a restart ([`key`]).
pub key: String,
pub name: String,
pub backend: Backend,
@@ -73,7 +74,7 @@ impl Candidate {
fn new(adapter: &wgpu::Adapter, surface: Option<&wgpu::Surface<'_>>) -> Self {
let info = adapter.get_info();
Self {
key: key(info.backend, info.vendor, info.device),
key: key(info.backend, info.vendor, info.device, &info.name),
presents: surface.is_none_or(|s| adapter.is_surface_supported(s)),
name: info.name,
backend: info.backend,
@@ -83,8 +84,15 @@ impl Candidate {
}
/// `backend:vendor:device`, e.g. `Vulkan:1002:164e` (PCI ids, as `MESA_VK_DEVICE_SELECT` takes them).
fn key(backend: Backend, vendor: u32, device: u32) -> String {
format!("{backend:?}:{vendor:04x}:{device:04x}")
/// Metal reports no ids (`0000:0000` for every GPU), so an adapter without them is `backend:name`
/// (`Metal:Intel Iris Pro Graphics`): otherwise leaving out the one that failed would leave out
/// every GPU of a dual-GPU Mac (#651). Commas, which separate [`SKIP_ENV`]'s keys, become spaces.
fn key(backend: Backend, vendor: u32, device: u32, name: &str) -> String {
if vendor == 0 && device == 0 {
format!("{backend:?}:{}", name.replace(',', " ").trim())
} else {
format!("{backend:?}:{vendor:04x}:{device:04x}")
}
}
/// The order to try `candidates` in (their indices): only those that present to the window and
@@ -307,6 +315,27 @@ mod tests {
/// A restart leaves out the adapter the window failed on, so Power Saving on the machine of
/// #502 ends up on the NVIDIA GPU, and once every adapter failed there is nothing to try.
/// A dual-GPU Mac reports both GPUs under Metal with no PCI ids (#651): their keys still
/// differ, so when the integrated GPU fails, the restart renders on the discrete one.
#[test]
fn gpus_without_pci_ids_are_told_apart_by_name() {
let mac = |name: &str, device_type| Candidate {
key: key(Backend::Metal, 0, 0, name),
name: name.into(),
backend: Backend::Metal,
device_type,
presents: true,
};
let c = vec![mac("NVIDIA GeForce GT 750M", DeviceType::DiscreteGpu), mac("Intel Iris Pro Graphics", DeviceType::IntegratedGpu)];
assert_eq!((c[0].key.as_str(), c[1].key.as_str()), ("Metal:NVIDIA GeForce GT 750M", "Metal:Intel Iris Pro Graphics"));
let first = adapter_order(&c, PowerPreference::LowPower, None, &[]);
assert_eq!(names(&c, &first)[0], "Intel Iris Pro Graphics");
let skip = parse_skip(&c[first[0]].key);
assert_eq!(names(&c, &adapter_order(&c, PowerPreference::LowPower, None, &skip)), ["NVIDIA GeForce GT 750M"]);
// A comma in a name can't split the list.
assert_eq!(parse_skip(&key(Backend::Gl, 0, 0, "Mesa, llvmpipe")), ["Gl:Mesa llvmpipe"]);
}
#[test]
fn a_restart_tries_the_next_adapter() {
let c = issue_502();
@@ -433,8 +462,8 @@ mod tests {
fn skipped_adapters_parse_from_a_comma_separated_list() {
assert_eq!(parse_skip("Vulkan:1002:164e, Gl:10de:2f04,,"), ["Vulkan:1002:164e", "Gl:10de:2f04"]);
assert!(parse_skip("").is_empty());
assert_eq!(key(Backend::Vulkan, 0x1002, 0x164e), "Vulkan:1002:164e");
assert_eq!(key(Backend::Gl, 0x10de, 0x2f04), "Gl:10de:2f04");
assert_eq!(key(Backend::Vulkan, 0x1002, 0x164e, "AMD Radeon"), "Vulkan:1002:164e");
assert_eq!(key(Backend::Gl, 0x10de, 0x2f04, "NVIDIA"), "Gl:10de:2f04");
}
#[test]
+40 -1
View File
@@ -43,10 +43,13 @@ struct App {
graphics_loss: GraphicsLoss,
/// The graphics device was lost and the unsaved changes are kept for Data Recovery.
graphics_lost: bool,
/// Frames the UI has run (see [`end_before_teardown`]).
frames: u64,
}
impl eframe::App for App {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.frames = ctx.cumulative_frame_nr();
if let Some(why) = self.graphics_loss.take() {
// eframe can't give a window a new device: the user saves and starts again.
self.graphics_lost = self.app.graphics_lost(&why);
@@ -87,6 +90,7 @@ impl eframe::App for App {
#[cfg(not(feature = "windows7"))]
fn on_exit(&mut self) {
save_prefs(&self.app);
end_before_teardown(self.frames);
}
#[cfg(feature = "windows7")]
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
@@ -94,6 +98,22 @@ impl eframe::App for App {
}
}
/// On macOS with accessibility, end the process once the app has saved what it keeps on quitting,
/// before eframe tears the window down (#661). AccessKit then gives the window's content view back
/// its own class, which AppKit's Touch Bar support was observing it under, and AppKit aborts the
/// app as it quits on Macs with a Touch Bar. Nothing after the window is needed on a normal quit.
/// Not while the window is still starting up (`frames` below [`gpu::STARTUP_FRAMES`]): a graphics
/// failure then starts the app again on another adapter (#651).
#[allow(unused_variables)]
fn end_before_teardown(frames: u64) {
#[cfg(all(target_os = "macos", feature = "accessibility", feature = "wgpu"))]
if frames >= gpu::STARTUP_FRAMES {
log::info!("quitting");
log::logger().flush();
std::process::exit(0);
}
}
/// Open files handed to the app (command line, macOS Finder and Dock) as documents. A file that
/// can't be opened is reported in the status bar and on stderr; the others still open.
fn open_files(app: &mut VectorcraftApp, files: Vec<String>) {
@@ -344,6 +364,18 @@ fn app_icon() -> egui::IconData {
eframe::icon_data::from_png_bytes(png).unwrap_or_default()
}
/// Windows shows a window's big icon (`ICON_BIG`) in the taskbar and Alt+Tab; eframe sets only the
/// small one (the title bar's), so the taskbar showed a generic icon. Set the big one too.
#[cfg(windows)]
fn set_taskbar_icon(w: &winit::window::Window) {
use winit::platform::windows::WindowExtWindows as _;
let icon = app_icon();
// An icon that can't be made leaves the generic one: nothing else depends on it.
if let Ok(i) = winit::window::Icon::from_rgba(icon.rgba, icon.width, icon.height) {
w.set_taskbar_icon(Some(i));
}
}
/// "name (backend, kind)" of the adapter the window renders with, for Help › About and bug reports.
#[cfg(feature = "wgpu")]
fn adapter_summary(info: &eframe::wgpu::AdapterInfo) -> String {
@@ -423,6 +455,11 @@ fn main() -> std::process::ExitCode {
// Only adapters that can show the window, in the order `gpu` gives (#306, #502).
if let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
create.native_adapter_selector = Some(gpu::selector(power, startup.clone()));
// Nothing draws or dispatches indirectly, so wgpu's check of indirect arguments only
// costs a compute shader at start-up, one some drivers can't compile (OCLP-patched
// Metal on an Iris Pro, #651). `WGPU_VALIDATION_INDIRECT_CALL=1` turns it back on.
create.instance_descriptor.flags =
(eframe::wgpu::InstanceFlags::from_build_config() - eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env();
}
options
};
@@ -444,6 +481,8 @@ fn main() -> std::process::ExitCode {
// Fit the window to its monitor, or put it back where it was (still hidden).
if let Some(w) = cc.winit_window() {
app.ui.window = Some(window::restore(w, saved_window));
#[cfg(windows)]
set_taskbar_icon(w);
}
// User Defined swatch and graphic style libraries live next to the preferences.
let swatches = prefs_path().and_then(|p| Some(p.parent()?.join("Swatches").to_string_lossy().to_string()));
@@ -490,7 +529,7 @@ fn main() -> std::process::ExitCode {
#[cfg(not(target_os = "macos"))]
let _ = in_window_menus;
open_files(&mut app, files);
Ok(Box::new(App { app, graphics_loss, graphics_lost: false }))
Ok(Box::new(App { app, graphics_loss, graphics_lost: false, frames: 0 }))
}),
)
});
+2 -1
View File
@@ -1,5 +1,6 @@
//! Windows: the font files in DirectWrite's system font collection, scanned with the font folders
//! (`vectorcraft_text::set_platform_font_files`).
//! (`vectorcraft_text::set_platform_font_files`). The desktop app and `vectorcraft-cli` (exports,
//! MCP) both install it; the CLI shares this file.
//!
//! Font services such as Adobe Fonts load their fonts with GDI at run time, from files in their
//! own folders, without installing them: the font folders and the registry don't list them. Since
+4
View File
@@ -31,3 +31,7 @@ sync_github_name = true
[people.omd0]
sync_github_name = true
[people.dotCooCoo]
real_name = "Robert Lee"
sync_github_name = true
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "vectorcraft-affinity"
description = "Bounded reader for native Affinity documents: archive, object stream and document model"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[lints]
workspace = true
[features]
# Synthetic-container builders for tests and fuzz seeds; not an Affinity exporter.
synth = []
[dependencies]
crc32fast = { workspace = true }
flate2 = { workspace = true }
ruzstd = "0.9"
[dev-dependencies]
proptest = { workspace = true }
+122
View File
@@ -0,0 +1,122 @@
# vectorcraft-affinity
An independent, bounded Rust reader for native Affinity documents: `.af` from Affinity 3 and
`.afdesign`, `.afphoto` and `.afpub` from Affinity 1 and 2 (container versions 8 to 12). It reads
the archive, the tagged object stream in `doc.dat` and the part of the document model listed below,
and hands the engine a document in pixels with every transform composed. It does not write
Affinity files.
## What is imported
| Affinity | VectorCraft |
|---|---|
| Pages, Publisher spreads (one or two pages) | artboards, spreads side by side |
| Artboards (`ShpN` with `ABEn`), with name, background fill, any transform | artboards plus a clip group with the background |
| Layers (`Scop`), groups (`Grup`), pass-through or isolated | layers and sublayers, groups |
| Curves (`PCrv`): cubic subpaths, closed flags, live corners (`CnrD`) | paths (multi-subpath curves fill even-odd) |
| Rectangles with corner radii (relative or absolute), ellipses, polygons (smooth too), stars, square stars, pies, triangles, trapezoids | paths |
| Compound shapes (`Comp`, add and subtract) | one even-odd path |
| A shape or curve with children clips them | clip group whose clipping path keeps its fill (behind) and stroke (over) |
| Vector masks (`AdCh` curves and shapes) | clip group |
| Pixel masks (`MRst`, attached or as a layer in a group) | opacity mask |
| Solid fills: RGBA, HSLA, CMYK, grey, Lab (D50) | fill appearance (alpha as fill opacity) |
| Linear, elliptical and radial gradients with Affinity's midpoint bias | gradients (the bias as the midpoint where the blend is half way) |
| Strokes: weight, scale with object, dash pattern and phase, alignment, miter limit, behind the fill | stroke appearance |
| Fill layers (`FRst`) | filled rectangles |
| Artistic and frame text: characters, font family/weight/style, size, tracking, fixed leading, colour, paragraph alignment, first baseline | point type and area type |
| Placed images (`ImgN`): the original JPEG/PNG embedded in the file | embedded images |
| Pixel layers (`Rstr`): RGBA 8/16-bit and CMYK 8-bit tiles, cropped to their content | embedded PNG images |
| Embedded documents and symbols (`EmbN`) | the picture Affinity cached of them |
| Opacity, visibility, lock, names, the blend modes VectorCraft has | the same |
Everything else is reported in the import warnings, one line per kind with a count, never
dropped silently: layer effects, adjustment layers and live filters, brush and pressure strokes,
transparency gradients, fill opacity, bitmap fills, master pages, conical gradients (as radial),
special shapes (cloud, heart, cog, callouts, arrows…, imported as their bounding ellipse), corner
types other than round, stars with rounded points, several fills or strokes on one object (the
active one is used), outlined or scaled text, text fields such as page numbers, frames with
columns or a curved outline, grey/Lab/32-bit pixels, CMYK pixels (converted to RGB without the
document's profile) and Affinity-only blend modes (Add, Linear Light… as Normal).
When the native document can't be read at all, the engine opens the embedded PNG preview instead,
with a warning that names the reason; that preview can't be placed, used as a template or mined
for swatches and styles. Save never writes back over the Affinity file.
## Provenance and clean-room
Affinity has no published file-format specification, so this reader is built only from a public
description of the format and from public files, never from Affinity itself. This is how the sibling app PhotoCraft already treats formats that
are only partly documented:
its PSD reader fills the gaps in Adobe's specification from the MIT-licensed psd-tools and
ag-psd, and its camera raw decoder recovered Nikon's compression tables by black-box analysis
of CC0 sample files.
* **No Affinity software.** Affinity was never downloaded, installed, run, scripted, screenshotted or
disassembled for this work, and nothing from an Affinity installation (program code, resources,
presets, fonts, colour profiles) was read. No file was made with Affinity for it: every sample
is a document its author published.
* **A public, permissively licensed description.** The container and object-stream layout was
learned from [VMDevCpp/afread](https://github.com/VMDevCpp/afread) (MIT, at
`04b672334a43e3e37ded6b5ffc57af231d589774`, written for container versions 7–11) and re-described
in our own words before this Rust code was written; no code was translated. No GPL/AGPL code (such
as Inkscape's Affinity extension) was read.
* **Every structure checked against public files.** afread doesn't say how its author learned the
format, so nothing here rests on it alone: each structure the reader relies on is confirmed by
the public documents. Every archive entry carries a CRC-32 that must match, and every field in
the object stream names its own type, so a misread layout fails loudly instead of producing
plausible wrong data.
* **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
(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.
* **Nothing of Affinity's is in this repository**: no program code, assets or documents. The test
files are fetched at pinned commits and sha256-verified, never committed.
* **Read-only.** Nothing is written in Affinity's format; export waits until someone can check
written files in Affinity itself.
The same reader is shared, as an independent copy, with PhotoCraft's `photocraft-affinity`.
## Validation
* Unit tests on synthetic containers built by the `synth` feature (stored, zlib and zstd entries,
checksums, budgets, cycles, hostile streams, every truncation) and property tests of random
mutations.
* `cargo xtask corpus --affinity` fetches 22 public CC0/MIT/Apache-2.0 documents, including the
four Affinity 3 `.af` files of [samuel-etver/vector-art](https://github.com/samuel-etver/vector-art)
(CC0), at pinned commits, each checked against `xtask/affinity-corpus.sha256`. `tests/real_files.rs`
parses them; `engine/tests/affinity_corpus.rs` renders each one and compares it with the
thumbnail Affinity saved in it (mean difference 0–4.4 of 255 at the thumbnail's size, with a
ceiling per file).
* `engine/tests/import_fuzz.rs` mutates a synthetic native document's stream and archive and checks
that whatever opens also renders and exports; `fuzz/` has `cargo-fuzz` targets for the whole
reader (`container`) and the object stream (`stream`):
```sh
cd crates/affinity
cargo +nightly fuzz run stream -- -max_total_time=600
```
What has not been verified: files from Affinity builds or platforms outside the public set, files
written by other applications, rotated or skewed images against Affinity's render, mask polarity
against a render with and without the mask, and anything listed as a warning above. The `synth`
builders write only what this reader accepts; Affinity has never opened their output, so they are
test fixtures, not an exporter.
## Safety limits
Every size is checked before it is allocated: 256 MiB per archive entry and 1 GiB per import by
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
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.
## Privacy
Affinity documents can hold the folder they were saved in, user names, original image paths and
XMP metadata. This reader never imports those fields.
+5
View File
@@ -0,0 +1,5 @@
/target
/corpus
/artifacts
/coverage
/Cargo.lock
+29
View File
@@ -0,0 +1,29 @@
[package]
name = "vectorcraft-affinity-fuzz"
version = "0.0.0"
edition = "2024"
publish = false
[package.metadata]
cargo-fuzz = true
[dependencies]
libfuzzer-sys = "0.4"
vectorcraft-affinity = { path = "..", features = ["synth"] }
[[bin]]
name = "container"
path = "fuzz_targets/container.rs"
test = false
doc = false
bench = false
[[bin]]
name = "stream"
path = "fuzz_targets/stream.rs"
test = false
doc = false
bench = false
[workspace]
members = ["."]
@@ -0,0 +1,10 @@
#![no_main]
use libfuzzer_sys::fuzz_target;
// The whole native reader (archive, object stream, model) and the preview, on arbitrary bytes.
fuzz_target!(|bytes: &[u8]| {
let _ = vectorcraft_affinity::container::header(bytes);
let _ = vectorcraft_affinity::preview(bytes);
let _ = vectorcraft_affinity::read(bytes, vectorcraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20 });
});
@@ -0,0 +1,11 @@
#![no_main]
use libfuzzer_sys::fuzz_target;
// The doc.dat object stream and the model built from it, wrapped in a stored archive entry so
// mutations reach the tagged grammar instead of the archive's checksum.
fuzz_target!(|stream: &[u8]| {
let _ = vectorcraft_affinity::stream::parse(stream);
let file = vectorcraft_affinity::synth::container(&[("doc.dat", stream, vectorcraft_affinity::synth::Method::Stored)], None);
let _ = vectorcraft_affinity::read(&file, vectorcraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20 });
});
+105
View File
@@ -0,0 +1,105 @@
//! Typed reads of [`Stream`] objects. Every accessor returns `None` when a field is missing or has
//! another type, so the document mapping treats odd data as absent rather than failing.
use crate::stream::{ObjId, Object, Stream, Tag, Value};
impl Stream {
pub fn object(&self, id: ObjId) -> Option<&Object> {
self.objects.get(id)
}
pub fn field(&self, id: ObjId, tag: &[u8; 4]) -> Option<&Value> {
self.object(id)?.get(Tag::of(tag))
}
pub fn class(&self, id: ObjId) -> Option<Tag> {
self.object(id).map(|o| o.class)
}
pub fn is(&self, id: ObjId, class: &[u8; 4]) -> bool {
self.class(id) == Some(Tag::of(class))
}
/// A child object (shared, linked or inline); `None` for null or missing.
pub fn obj(&self, id: ObjId, tag: &[u8; 4]) -> Option<ObjId> {
match self.field(id, tag)? {
Value::Obj(o) => *o,
_ => None,
}
}
/// An array of objects (nulls skipped).
pub fn objs(&self, id: ObjId, tag: &[u8; 4]) -> Vec<ObjId> {
match self.field(id, tag) {
Some(Value::Array(a)) => a.iter().filter_map(|v| if let Value::Obj(Some(o)) = v { Some(*o) } else { None }).collect(),
_ => Vec::new(),
}
}
pub fn f64(&self, id: ObjId, tag: &[u8; 4]) -> Option<f64> {
match self.field(id, tag)? {
Value::Float(f) if f.is_finite() => Some(*f),
_ => None,
}
}
pub fn int(&self, id: ObjId, tag: &[u8; 4]) -> Option<i64> {
match self.field(id, tag)? {
Value::Int(i) => Some(*i),
Value::UInt(u) => i64::try_from(*u).ok(),
_ => None,
}
}
pub fn bool(&self, id: ObjId, tag: &[u8; 4]) -> Option<bool> {
match self.field(id, tag)? {
Value::Bool(b) => Some(*b),
_ => None,
}
}
pub fn str(&self, id: ObjId, tag: &[u8; 4]) -> Option<&str> {
match self.field(id, tag)? {
Value::Str(s) => Some(s),
_ => None,
}
}
pub fn enumeration(&self, id: ObjId, tag: &[u8; 4]) -> Option<(u16, u16)> {
match self.field(id, tag)? {
Value::Enum { id, version } => Some((*id, *version)),
_ => None,
}
}
/// A fixed vector of `N` finite floats (points, rectangles, transforms).
pub fn floats<const N: usize>(&self, id: ObjId, tag: &[u8; 4]) -> Option<[f64; N]> {
match self.field(id, tag)? {
Value::Floats(v) if v.len() == N && v.iter().all(|f| f.is_finite()) => {
let mut a = [0.0; N];
a.copy_from_slice(v);
Some(a)
}
_ => None,
}
}
pub fn bytes(&self, id: ObjId, tag: &[u8; 4]) -> Option<&[u8]> {
match self.field(id, tag)? {
Value::Bytes(b) | Value::Blob(b) => Some(b),
_ => None,
}
}
pub fn entry(&self, id: ObjId, tag: &[u8; 4]) -> Option<&str> {
match self.field(id, tag)? {
Value::Entry(s) => Some(s),
_ => None,
}
}
/// Untagged fields of a positional object, in order.
pub fn positional(&self, id: ObjId) -> impl Iterator<Item = &Value> {
self.object(id).into_iter().flat_map(|o| o.fields.iter().map(|(_, v)| v))
}
}
+371
View File
@@ -0,0 +1,371 @@
//! The Affinity archive: header, the chain of file allocation tables (one per save) and
//! bounded, CRC-checked extraction of named entries such as `doc.dat` and raster tiles.
use std::collections::{HashMap, HashSet};
use std::io::Read;
use crate::Error;
pub const MAGIC: &[u8; 4] = b"\x00\xffKA";
/// Oldest and newest container versions seen in real documents (Affinity 1.x writes 8,
/// Affinity 3 writes 12). Version 7 is described by afread but no sample exists.
pub const VERSIONS: std::ops::RangeInclusive<u16> = 8..=12;
/// `Prsn` (stored byte-reversed): documents. Asset, brush and style libraries share the magic.
const DOCUMENT_KIND: &[u8; 4] = b"nsrP";
/// A save appends one table; real files have one to a few hundred.
const MAX_TABLES: usize = 4096;
/// zstd window ceiling: real documents use a few MiB; this stops a header from reserving gigabytes.
const MAX_ZSTD_WINDOW: u64 = 64 << 20;
/// Decompression budget for one import. Every entry declares its size, which is checked against
/// these limits before anything is allocated, and the decoded length must match it exactly.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Limits {
/// Largest single entry (`doc.dat` or one tile).
pub max_entry: usize,
/// Total bytes extracted over the whole import.
pub max_total: usize,
}
impl Default for Limits {
fn default() -> Self {
Self { max_entry: 256 << 20, max_total: 1 << 30 }
}
}
/// The container header, all integers little-endian.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Header {
pub version: u16,
/// Seen as 0, 4 or 8 in real files; bits 0 and 1 mark container variants that are rejected.
pub flags: u16,
pub(crate) fat_offset: u64,
pub(crate) thumbnail_offset: u64,
/// Save time in Unix seconds, as written by the application.
pub saved: u32,
}
/// One FAT record, as of the newest table that mentions its id.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Entry {
pub id: u32,
pub name: String,
offset: u64,
size: u64,
compressed: u64,
crc: u32,
compression: u8,
}
impl Entry {
/// Decompressed size declared by the table.
pub fn size(&self) -> u64 {
self.size
}
}
#[derive(Debug)]
pub struct Archive<'a> {
bytes: &'a [u8],
pub header: Header,
/// Head revision of every named, non-deleted entry.
entries: HashMap<String, Entry>,
/// Number of tables (saves) in the chain.
pub revisions: usize,
extracted: usize,
limits: Limits,
}
pub(crate) struct Cursor<'a> {
bytes: &'a [u8],
pub(crate) pos: usize,
}
impl<'a> Cursor<'a> {
pub(crate) fn new(bytes: &'a [u8], pos: usize) -> Self {
Self { bytes, pos }
}
pub(crate) fn remaining(&self) -> usize {
self.bytes.len().saturating_sub(self.pos)
}
pub(crate) fn take(&mut self, n: usize) -> Result<&'a [u8], Error> {
let end = self.pos.checked_add(n).ok_or(Error::Malformed("length overflow"))?;
let s = self.bytes.get(self.pos..end).ok_or(Error::Malformed("truncated data"))?;
self.pos = end;
Ok(s)
}
pub(crate) fn array<const N: usize>(&mut self) -> Result<[u8; N], Error> {
let s = self.take(N)?;
let mut a = [0u8; N];
a.copy_from_slice(s);
Ok(a)
}
pub(crate) fn u8(&mut self) -> Result<u8, Error> {
Ok(self.array::<1>()?[0])
}
pub(crate) fn u16(&mut self) -> Result<u16, Error> {
Ok(u16::from_le_bytes(self.array()?))
}
pub(crate) fn u32(&mut self) -> Result<u32, Error> {
Ok(u32::from_le_bytes(self.array()?))
}
pub(crate) fn u64(&mut self) -> Result<u64, Error> {
Ok(u64::from_le_bytes(self.array()?))
}
}
fn offset(v: u64) -> Result<usize, Error> {
usize::try_from(v).map_err(|_| Error::Malformed("offset beyond addressable memory"))
}
/// True when `bytes` start with the Affinity container signature (documents and libraries).
pub fn is_affinity(bytes: &[u8]) -> bool {
bytes.starts_with(MAGIC)
}
/// Check the signature, version and document kind.
pub fn header(bytes: &[u8]) -> Result<Header, Error> {
if !is_affinity(bytes) {
return Err(Error::Malformed("missing container signature"));
}
let mut c = Cursor::new(bytes, 4);
let version = c.u16()?;
let flags = c.u16()?;
if !VERSIONS.contains(&version) {
return Err(Error::Unsupported("unknown container version"));
}
if flags & 3 != 0 {
return Err(Error::Unsupported("container variant"));
}
if c.take(4)? != DOCUMENT_KIND {
return Err(Error::Unsupported("this Affinity file is a library (assets, brushes or styles), not a document"));
}
if c.take(4)? != b"#Inf" {
return Err(Error::Malformed("missing information header"));
}
let fat_offset = c.u64()?;
let thumbnail_offset = c.u64()?;
c.take(16)?;
let saved = u32::try_from(c.u64()?).unwrap_or(0);
c.take(8)?;
if c.take(4)? != b"Prot" {
return Err(Error::Malformed("missing protocol header"));
}
Ok(Header { version, flags, fat_offset, thumbnail_offset, saved })
}
impl<'a> Archive<'a> {
pub fn open(bytes: &'a [u8], limits: Limits) -> Result<Self, Error> {
let header = header(bytes)?;
// Newest record per id: (table date, table index, record).
let mut latest: HashMap<u32, (u64, usize, Option<Entry>)> = HashMap::new();
let mut names: HashMap<u32, String> = HashMap::new();
let mut seen = HashSet::new();
let mut next = header.fat_offset;
let mut tables = 0usize;
while next != 0 {
if !seen.insert(next) {
return Err(Error::Malformed("allocation tables form a cycle"));
}
tables += 1;
if tables > MAX_TABLES {
return Err(Error::Limit("too many saved revisions"));
}
let mut c = Cursor::new(bytes, offset(next)?);
let tag = c.array::<4>()?;
let level = match &tag {
b"#FAT" => 1,
b"#FT2" => 2,
b"#FT3" => 3,
b"#FT4" => 4,
_ => return Err(Error::Malformed("missing allocation table")),
};
next = c.u64()?;
let date = c.u64()?;
c.take(24)?;
let files = c.u32()?;
c.take(8)?;
let dirs = c.u16()?;
c.take(1)?;
for _ in 0..files {
let id = c.u32()?;
let flag = c.u8()?;
let mut record = None;
match flag {
0 | 1 => {
let offset = c.u64()?;
let size = c.u64()?;
let compressed = c.u64()?;
let crc = c.u32()?;
let stored = c.u8()?;
if level >= 2 {
c.take(4)?;
}
if level >= 4 {
c.take(4)?;
}
// Old tables number the methods; newer ones store the method byte itself.
let compression = if level <= 2 {
match stored {
1 => 0x01,
2 => 0x41,
3 => 0x81,
4 => 0xC1,
_ => 0,
}
} else {
stored
};
record = Some(Entry { id, name: String::new(), offset, size, compressed, crc, compression });
}
2 => {}
_ => return Err(Error::Malformed("unknown allocation record")),
}
if flag == 0 {
let len = usize::from(c.u16()?);
let name = String::from_utf8_lossy(c.take(len)?).into_owned();
names.entry(id).or_insert(name);
}
// Equal dates: the first table in the chain (the newest) wins, as in afread.
let replace = latest.get(&id).is_none_or(|(d, _, _)| date > *d);
if replace {
latest.insert(id, (date, tables, record));
}
}
for _ in 0..dirs {
let len = usize::from(c.u16()?);
c.take(10)?;
c.take(len)?;
}
}
let mut entries = HashMap::new();
for (id, (_, _, record)) in latest {
if let (Some(mut e), Some(name)) = (record, names.get(&id)) {
e.name = name.clone();
entries.insert(e.name.clone(), e);
}
}
Ok(Self { bytes, header, entries, revisions: tables, extracted: 0, limits })
}
pub fn entry(&self, name: &str) -> Option<&Entry> {
self.entries.get(name)
}
pub fn entry_names(&self) -> impl Iterator<Item = &str> {
self.entries.keys().map(String::as_str)
}
/// Bytes extracted so far, counted against [`Limits::max_total`].
pub fn extracted(&self) -> usize {
self.extracted
}
/// Extract an entry by name: decompress, undo the byte predictor and verify the CRC.
pub fn read(&mut self, name: &str) -> Result<Vec<u8>, Error> {
let e = self.entries.get(name).ok_or(Error::Malformed("missing archive entry"))?.clone();
let size = usize::try_from(e.size).map_err(|_| Error::Limit("archive entry too large"))?;
if size > self.limits.max_entry {
return Err(Error::Limit("archive entry too large"));
}
let total = self.extracted.checked_add(size).ok_or(Error::Limit("document too large"))?;
if total > self.limits.max_total {
return Err(Error::Limit("document too large"));
}
let mut c = Cursor::new(self.bytes, offset(e.offset)?);
if c.take(4)? != b"#Fil" {
return Err(Error::Malformed("archive entry has no data block"));
}
let method = e.compression & 3;
let stored_len = if matches!(method, 1 | 2) { e.compressed } else { e.size };
let data = c.take(offset(stored_len)?)?;
let mut out = match method {
1 => inflate(data, size)?,
2 => unzstd(data, size)?,
_ => data.to_vec(),
};
if out.len() != size {
return Err(Error::Malformed("archive entry size mismatch"));
}
unpredict(&mut out, e.compression)?;
if crc32fast::hash(&out) != e.crc {
return Err(Error::Malformed("archive entry checksum mismatch"));
}
self.extracted = total;
Ok(out)
}
}
/// Read at most `size + 1` bytes so a stream that decodes past its declared size is caught
/// without unbounded allocation.
fn read_bounded(mut r: impl Read, size: usize) -> Result<Vec<u8>, Error> {
let mut out = Vec::with_capacity(size.min(64 << 20));
let cap = u64::try_from(size).unwrap_or(u64::MAX).saturating_add(1);
r.by_ref().take(cap).read_to_end(&mut out).map_err(|_| Error::Malformed("corrupt compressed entry"))?;
Ok(out)
}
fn inflate(data: &[u8], size: usize) -> Result<Vec<u8>, Error> {
read_bounded(flate2::read::ZlibDecoder::new(data), size)
}
fn unzstd(data: &[u8], size: usize) -> Result<Vec<u8>, Error> {
let mut src = data;
let mut out = Vec::new();
// Entries may hold several concatenated frames.
while !src.is_empty() {
let left = size.checked_sub(out.len()).ok_or(Error::Malformed("archive entry size mismatch"))?;
let decoder = ruzstd::decoding::StreamingDecoder::new_with_max_window_size(&mut src, MAX_ZSTD_WINDOW)
.map_err(|_| Error::Malformed("corrupt compressed entry"))?;
let frame = read_bounded(decoder, left)?;
if frame.is_empty() {
return Err(Error::Malformed("corrupt compressed entry"));
}
out.extend_from_slice(&frame);
if out.len() > size {
return Err(Error::Malformed("archive entry size mismatch"));
}
}
Ok(out)
}
/// Undo the byte predictor selected by the high bits of the compression byte.
fn unpredict(b: &mut [u8], compression: u8) -> Result<(), Error> {
match (compression & 0xC0, compression & 0x20 != 0) {
(0x00, _) => {}
(0x40, _) => byte_delta(b),
(0x80, false) => {
let mut prev = 0u16;
for pair in b.chunks_exact_mut(2) {
let v = u16::from_le_bytes([pair[0], pair[1]]).wrapping_add(prev);
pair.copy_from_slice(&v.to_le_bytes());
prev = v;
}
}
(0x80, true) => {
byte_delta(b);
// A 64 KiB tile of 16-bit samples is stored as all high bytes, then all low bytes.
if b.len() == 0x10000 {
let planes = b.to_vec();
let (hi, lo) = planes.split_at(0x8000);
for (i, out) in b.chunks_exact_mut(4).enumerate() {
let (Some(h), Some(l)) = (hi.get(2 * i..2 * i + 2), lo.get(2 * i..2 * i + 2)) else {
return Err(Error::Malformed("tile size"));
};
out.copy_from_slice(&[l[0], h[0], l[1], h[1]]);
}
}
}
_ => return Err(Error::Unsupported("archive entry predictor")),
}
Ok(())
}
fn byte_delta(b: &mut [u8]) {
let mut prev = 0u8;
for v in b {
*v = v.wrapping_add(prev);
prev = *v;
}
}
+175
View File
@@ -0,0 +1,175 @@
//! Outlines of curves (`PCvD` node lists), parametric shapes (`Shpe` objects) and compounds, in
//! node space.
use crate::model::{Affine, Path, Point, Reader, SubPath};
use crate::stream::{ObjId, Tag, Value};
/// Nodes per curve object.
const MAX_NODES: usize = 4_000_000;
/// The node-space outline of a curve, shape or compound node, and whether it fills even-odd.
/// Instances (master-page or symbol copies) take the geometry of their master.
pub(crate) fn outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine) -> Option<(Path, bool)> {
let s = r.s;
match &class.0.to_be_bytes() {
b"PCrv" | b"TxtC" => {
let crvs = s.obj(id, b"Crvs").or_else(|| s.obj(id, b"MCrM").and_then(|m| s.obj(m, b"Crvs")))?;
let path = curves(r, crvs)?;
let multi = path.subpaths.len() > 1;
Some((path, multi))
}
b"Comp" => {
// One path of all the operands' contours in the compound's space, filled even-odd: this
// matches add and subtract operands in every public sample; a true union of overlapping
// added operands would differ.
let mut path = Path::default();
for c in s.objs(id, b"Chld") {
let local = s.floats::<6>(c, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY);
let class = s.class(c)?;
if s.enumeration(c, b"ComO").is_some_and(|(op, _)| op != 0 && op != 2) {
r.warn("compound shapes that intersect or exclude (imported as add)");
}
if let Some((p, _)) = outline(r, c, class, local.then(world)) {
path.subpaths.extend(p.transformed(local).subpaths);
}
}
Some((path, true))
}
_ => {
let (shape, box_owner) = match s.obj(id, b"Shpe") {
Some(sh) => (sh, id),
None => {
let master = s.obj(id, b"ShpM")?;
(s.obj(master, b"Shpe")?, master)
}
};
let b = s.floats::<4>(box_owner, b"ShpB")?;
crate::shapes::shape(r, shape, b, world).map(|p| (p, false))
}
}
}
/// `PCvD.Data`: positional `[u8, u32 count, then per subpath: bool closed, 18-byte records]`.
/// A record is (f64 x, f64 y, u8 kind, u8 role): role 0 is an anchor, 1 the out-handle of the
/// anchor before it, 2 the in-handle of the anchor after it. A closed subpath repeats its first
/// anchor at the end. `CnrD` live corners are rounded on the stored polyline.
pub(crate) fn curves(r: &mut Reader, crvs: ObjId) -> Option<Path> {
let s = r.s;
let data = s.obj(crvs, b"Data")?;
let mut fields = s.positional(data);
fields.next()?;
let count = match fields.next()? {
Value::UInt(n) => usize::try_from(*n).ok()?,
_ => return None,
};
let corners = s.obj(crvs, b"CnrD").and_then(|c| corner_radii(s.positional(c).collect()));
let mut path = Path::default();
let mut seen = 0usize;
for index in 0..count {
let closed = match fields.next()? {
Value::Bool(b) => *b,
_ => return None,
};
let Value::Array(records) = fields.next()? else { return None };
seen = seen.checked_add(records.len())?;
if seen > MAX_NODES {
r.warn("curves with more than four million nodes");
return None;
}
let anchors = anchors(records)?;
let radii = if index == 0 { corners.as_ref() } else { None };
if let Some(sp) = subpath(&anchors, closed, radii) {
path.subpaths.push(sp);
}
}
if corners.is_some() && count > 1 {
r.warn("rounded corners on curves with several subpaths (only the first is rounded)");
}
Some(path)
}
struct Anchor {
p: Point,
inh: Option<Point>,
out: Option<Point>,
/// Index of the anchor's record (live corners refer to records).
record: usize,
}
fn record(v: &Value) -> Option<(Point, u8)> {
let Value::Bytes(b) = v else { return None };
if b.len() != 18 {
return None;
}
let x = f64::from_le_bytes(b.get(0..8)?.try_into().ok()?);
let y = f64::from_le_bytes(b.get(8..16)?.try_into().ok()?);
if !x.is_finite() || !y.is_finite() {
return None;
}
Some((Point { x, y }, *b.get(17)?))
}
fn anchors(records: &[Value]) -> Option<Vec<Anchor>> {
let mut out: Vec<Anchor> = Vec::with_capacity(records.len() / 3 + 1);
let mut pending_in = None;
for (i, v) in records.iter().enumerate() {
let (p, role) = record(v)?;
match role {
0 => {
out.push(Anchor { p, inh: pending_in.take(), out: None, record: i });
}
1 => {
// A leading out-handle without an anchor is edit state only.
if let Some(a) = out.last_mut() {
a.out = Some(p);
}
}
2 => pending_in = Some(p),
_ => return None,
}
}
Some(out)
}
fn subpath(anchors: &[Anchor], closed: bool, radii: Option<&Vec<(usize, f64)>>) -> Option<SubPath> {
let first = anchors.first()?;
if let Some(radii) = radii.filter(|r| !r.is_empty()) {
let corner = |a: &Anchor| radii.iter().filter(|(rec, _)| *rec == a.record).map(|(_, r)| *r).fold(0.0, f64::max);
let polyline = anchors.iter().all(|a| a.inh.is_none_or(|h| h == a.p) && a.out.is_none_or(|h| h == a.p));
if polyline {
let mut pts: Vec<(Point, f64)> = anchors.iter().map(|a| (a.p, corner(a))).collect();
// A closed subpath repeats its first anchor; its radius may be on either copy.
if closed
&& pts.len() > 1
&& pts.first().map(|p| p.0) == pts.last().map(|p| p.0)
&& let Some((_, r)) = pts.pop()
&& let Some(f) = pts.first_mut()
{
f.1 = f.1.max(r);
}
return Some(crate::shapes::fillet(&pts, closed));
}
}
let segments = anchors
.windows(2)
.filter_map(|w| {
let [a, b] = w else { return None };
Some([a.out.unwrap_or(a.p), b.inh.unwrap_or(b.p), b.p])
})
.collect();
Some(SubPath { start: first.p, segments, closed })
}
/// `CnrD`: positional `[u8, i32[] record indices, f64[] radii, u8[] types]`.
fn corner_radii(fields: Vec<&Value>) -> Option<Vec<(usize, f64)>> {
let (Some(Value::Array(idx)), Some(Value::Array(rad))) = (fields.get(1), fields.get(2)) else { return None };
Some(
idx.iter()
.zip(rad)
.filter_map(|(i, r)| match (i, r) {
(Value::Int(i), Value::Float(r)) if r.is_finite() && *r > 0.0 => Some((usize::try_from(*i).ok()?, *r)),
_ => None,
})
.collect(),
)
}
+51
View File
@@ -0,0 +1,51 @@
//! Reader for native Affinity documents (`.af`, and `.afdesign`/`.afphoto`/`.afpub` from
//! Affinity 1 and 2): the archive container, the tagged object stream in `doc.dat`, and the
//! subset of the document model VectorCraft imports.
//!
//! Layouts follow the MIT-licensed afread reference and were checked against public documents
//! saved by Affinity 1.x to 3.x (container versions 8 to 12); see the crate README for provenance
//! and the exact supported subset. Nothing here writes Affinity files.
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use std::fmt;
mod access;
pub mod container;
mod geometry;
pub mod model;
pub mod paint;
pub mod preview;
mod raster;
mod shapes;
pub mod stream;
#[cfg(any(test, feature = "synth"))]
pub mod synth;
mod text;
pub use container::{Archive, Limits, MAGIC, is_affinity};
pub use model::{Document, read};
pub use preview::{MAX_PREVIEW_BYTES, MAX_PREVIEW_DIMENSION, Preview, preview};
/// Why a file could not be read. Messages are short reasons; callers add advice.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Error {
Malformed(&'static str),
Unsupported(&'static str),
Limit(&'static str),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Malformed(s) => write!(f, "damaged Affinity file: {s}"),
Self::Unsupported(s) => write!(f, "unsupported Affinity file: {s}"),
Self::Limit(s) => write!(f, "Affinity file exceeds a safety limit: {s}"),
}
}
}
impl std::error::Error for Error {}
#[cfg(test)]
mod tests;
+594
View File
@@ -0,0 +1,594 @@
//! The document model VectorCraft imports: spreads, artboards, layers, groups, curves, shapes,
//! text and images, with transforms composed into document space. Anything else is reported
//! by [`Document::warnings`] instead of being dropped silently.
use std::collections::{BTreeMap, HashSet};
use crate::paint::{self, Paint, Stroke};
use crate::stream::{ObjId, Stream, Tag};
use crate::{Archive, Error, Limits, stream};
/// Affine map `x' = a·x + c·y + e`, `y' = b·x + d·y + f` (kurbo's coefficient order).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Affine(pub [f64; 6]);
impl Affine {
pub const IDENTITY: Self = Self([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
/// Affinity's `Xfrm`, stored row by row: (a, c, e, b, d, f).
pub(crate) fn from_xfrm(m: [f64; 6]) -> Self {
Self([m[0], m[3], m[1], m[4], m[2], m[5]])
}
pub fn then(self, outer: Self) -> Self {
let [a, b, c, d, e, f] = self.0;
let [oa, ob, oc, od, oe, of] = outer.0;
Self([oa * a + oc * b, ob * a + od * b, oa * c + oc * d, ob * c + od * d, oa * e + oc * f + oe, ob * e + od * f + of])
}
pub fn apply(&self, p: Point) -> Point {
let [a, b, c, d, e, f] = self.0;
Point { x: a * p.x + c * p.y + e, y: b * p.x + d * p.y + f }
}
/// Geometric mean of the axis scales: how much a stroke width or font size grows.
pub fn scale(&self) -> f64 {
let [a, b, c, d, ..] = self.0;
(a * d - b * c).abs().sqrt()
}
pub fn is_finite(&self) -> bool {
self.0.iter().all(|v| v.is_finite())
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Point {
pub x: f64,
pub y: f64,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Rect {
pub x0: f64,
pub y0: f64,
pub x1: f64,
pub y1: f64,
}
/// One cubic Bézier subpath, in document pixels.
#[derive(Debug, Clone, PartialEq)]
pub struct SubPath {
pub start: Point,
/// (control 1, control 2, end) per segment.
pub segments: Vec<[Point; 3]>,
pub closed: bool,
}
/// An outline in document pixels.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Path {
pub subpaths: Vec<SubPath>,
}
impl Path {
pub fn transformed(&self, m: Affine) -> Self {
Self {
subpaths: self
.subpaths
.iter()
.map(|s| SubPath {
start: m.apply(s.start),
segments: s.segments.iter().map(|seg| seg.map(|p| m.apply(p))).collect(),
closed: s.closed,
})
.collect(),
}
}
}
/// Affinity's blend modes, numbered by the `Blnd` enum.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Blend {
#[default]
Normal,
PassThrough,
Darken,
DarkerColor,
Multiply,
ColorBurn,
Lighten,
LighterColor,
Screen,
ColorDodge,
Add,
Overlay,
SoftLight,
HardLight,
VividLight,
PinLight,
LinearLight,
HardMix,
Difference,
Exclusion,
Subtract,
Hue,
Saturation,
Luminosity,
Color,
Average,
Negation,
Reflect,
Glow,
Erase,
}
impl Blend {
/// The enum (id, version) pairs observed in a document whose layers are named after their mode.
fn from_enum(id: u16, version: u16) -> Option<Self> {
use Blend::*;
Some(match (id, version) {
(0, _) => Normal,
(1, 0) => Darken,
(2, 1) => DarkerColor,
(2, 0) => Multiply,
(3, 0) => ColorBurn,
(4, 0) => Lighten,
(6, 1) => LighterColor,
(5, 0) => Screen,
(6, 0) => ColorDodge,
(7, 0) => Add,
(8, 0) => Overlay,
(9, 0) => SoftLight,
(10, 0) => HardLight,
(11, 0) => VividLight,
(12, 0) => PinLight,
(15, 1) => LinearLight,
(13, 0) => HardMix,
(14, 0) => Difference,
(15, 0) => Exclusion,
(16, 0) => Subtract,
(17, 0) => Hue,
(18, 0) => Saturation,
(19, 0) => Luminosity,
(20, 0) => Color,
(21, 0) => Average,
(22, 0) => Negation,
(23, 0) => Reflect,
(24, 0) => Glow,
(25, 0) => Erase,
_ => return None,
})
}
}
/// A run of text with one set of character attributes.
#[derive(Debug, Clone, PartialEq)]
pub struct TextRun {
pub text: String,
/// PostScript name and family of the chosen font.
pub postscript: String,
pub family: String,
/// CSS weight (400 regular, 700 bold).
pub weight: i64,
pub italic: bool,
/// Font size in document pixels, before the node's transform.
pub size: f64,
/// Tracking in em.
pub tracking: f64,
/// Fixed line pitch in document pixels, when the run overrides automatic leading.
pub leading: Option<f64>,
pub fill: Paint,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Align {
Left,
Center,
Right,
Justify,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Text {
pub runs: Vec<TextRun>,
pub align: Align,
/// Artistic text: the first baseline's anchor point in node space (start, centre or end of the
/// line according to `align`). Frame text: the frame box in node space.
pub anchor: Point,
pub frame: Option<Rect>,
/// Node space to document pixels.
pub transform: Affine,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Pixels {
/// Straight-alpha RGBA, 8 bits per channel, row by row.
Rgba8(Vec<u8>),
/// The original file a placed image was made from (PNG, JPEG…), as embedded.
Encoded(Vec<u8>),
}
#[derive(Debug, Clone, PartialEq)]
pub struct Image {
pub width: u32,
pub height: u32,
pub pixels: Pixels,
/// Pixel space (0..w, 0..h) to document pixels.
pub transform: Affine,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Kind {
/// Layer container (`Scop`).
Layer,
Group,
/// Artboard: a rectangle that clips its children; `background` paints behind them.
Artboard {
rect: Rect,
background: Vec<Paint>,
},
/// Curve, parametric shape or compound shape: an outline with its fills and strokes.
/// Children of a shape are clipped by its outline (painted over its fills, under its strokes).
Shape {
path: Path,
fills: Vec<Paint>,
strokes: Vec<Stroke>,
even_odd: bool,
},
Text(Text),
Image(Image),
/// Something this reader does not import; its children are still imported.
Unsupported,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Node {
pub class: Tag,
pub name: String,
pub visible: bool,
pub locked: bool,
pub opacity: f64,
pub blend: Blend,
pub kind: Kind,
/// Vector mask in document pixels: the node shows only inside it (nonzero fill).
pub mask: Option<Path>,
/// Pixel mask: grey levels (white shows the node, black hides it); outside it the node is hidden.
pub pixel_mask: Option<Image>,
/// Bottom to top.
pub children: Vec<Node>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Spread {
/// Spread bounds in document pixels (the canvas, or the union of the artboards).
pub bounds: Rect,
/// Pages of a Publisher-style spread (one, or two facing pages), in document pixels.
pub pages: Vec<Rect>,
/// Transparent background; otherwise the page is white.
pub transparent: bool,
/// Bottom to top.
pub nodes: Vec<Node>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Document {
/// Pixels per inch: all geometry is in document pixels at this resolution.
pub dpi: f64,
pub spreads: Vec<Spread>,
/// Application that last saved the file, as stored (`Affinity 3.0.2`), when present.
pub saved_by: Option<String>,
/// Content that was not imported or was approximated, one line per kind, deduplicated.
pub warnings: Vec<String>,
}
/// Deepest layer nesting followed; deeper content is reported, not imported.
const MAX_TREE_DEPTH: usize = 128;
/// Total layer nodes imported.
const MAX_NODES: usize = 500_000;
/// Read a document: archive, `doc.dat` and the model.
pub fn read(bytes: &[u8], limits: Limits) -> Result<Document, Error> {
let mut archive = Archive::open(bytes, limits)?;
let doc = archive.read("doc.dat")?;
let s = stream::parse(&doc)?;
Reader::new(&s, &mut archive).document()
}
pub(crate) struct Reader<'s, 'a, 'b> {
pub(crate) s: &'s Stream,
pub(crate) archive: &'b mut Archive<'a>,
pub(crate) dpi: f64,
warnings: BTreeMap<String, usize>,
nodes: usize,
active: HashSet<ObjId>,
}
impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
fn new(s: &'s Stream, archive: &'b mut Archive<'a>) -> Self {
Self { s, archive, dpi: 72.0, warnings: BTreeMap::new(), nodes: 0, active: HashSet::new() }
}
pub(crate) fn warn(&mut self, what: impl Into<String>) {
*self.warnings.entry(what.into()).or_default() += 1;
}
fn document(mut self) -> Result<Document, Error> {
let s = self.s;
let root = s.root;
self.dpi = s.obj(root, b"UVCn").and_then(|u| s.f64(u, b"UPPI")).filter(|d| *d > 0.0 && *d < 100_000.0).unwrap_or(72.0);
let saved_by = s.obj(root, b"NVer").map(|v| {
let product = s.str(v, b"Prod").unwrap_or("Affinity");
match (s.int(v, b"Majr"), s.int(v, b"Minr"), s.int(v, b"Bild")) {
(Some(a), Some(b), Some(c)) => format!("{product} {a}.{b}.{c}"),
_ => product.to_string(),
}
});
let doc = s.obj(root, b"DocR").ok_or(Error::Malformed("document has no root node"))?;
let mut spreads = Vec::new();
for spread in s.objs(doc, b"Chld") {
if !s.is(spread, b"Sprd") {
self.warn("an unknown kind of page");
continue;
}
let pages = pages(s, spread);
let default_size =
s.floats::<2>(doc, b"DfSz").filter(|[w, h]| *w > 0.0 && *h > 0.0).map(|[w, h]| Rect { x0: 0.0, y0: 0.0, x1: w, y1: h });
let bounds = s.floats::<4>(spread, b"SprB").map(rect).or_else(|| pages.iter().copied().reduce(union)).or(default_size).unwrap_or(Rect {
x0: 0.0,
y0: 0.0,
x1: 0.0,
y1: 0.0,
});
let transparent = s.bool(spread, b"SprT").unwrap_or(false);
let (nodes, mask) = self.children(spread, Affine::IDENTITY, 0)?;
if mask.is_some() {
self.warn("mask layers directly on a page (imported without them)");
}
spreads.push(Spread { bounds, pages, transparent, nodes });
}
if spreads.is_empty() {
return Err(Error::Malformed("document has no pages"));
}
if !s.objs(doc, b"MpCh").is_empty() {
self.warn("master pages (their content is not placed on the pages that use them)");
}
let warnings = self.warnings.into_iter().map(|(w, n)| if n > 1 { format!("{w} ({n}×)") } else { w }).collect();
Ok(Document { dpi: self.dpi, spreads, saved_by, warnings })
}
/// The children of `parent`, and the pixel mask a mask layer among them puts on the parent.
fn children(&mut self, parent: ObjId, world: Affine, depth: usize) -> Result<(Vec<Node>, Option<Image>), Error> {
let s = self.s;
let mut out = Vec::new();
let mut mask = None;
for c in s.objs(parent, b"Chld") {
if s.is(c, b"MRst") {
// A mask layer inside a group or layer masks that container.
if s.bool(c, b"Visi") == Some(false) {
continue;
}
let local = s.floats::<6>(c, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY);
match crate::raster::mask(self, c, local.then(world))? {
Some(m) if mask.is_none() => mask = Some(m),
Some(_) => self.warn("several pixel masks on one layer (only the first is used)"),
None => {}
}
continue;
}
if let Some(n) = self.node(c, world, depth + 1)? {
out.push(n);
}
}
Ok((out, mask))
}
fn node(&mut self, id: ObjId, parent: Affine, depth: usize) -> Result<Option<Node>, Error> {
let s = self.s;
if depth > MAX_TREE_DEPTH {
self.warn("layers nested deeper than 128 levels");
return Ok(None);
}
self.nodes += 1;
if self.nodes > MAX_NODES {
return Err(Error::Limit("too many layers"));
}
// A node that contains itself (a shared object linked back into its own subtree).
if !self.active.insert(id) {
self.warn("a layer that contains itself");
return Ok(None);
}
let local = s.floats::<6>(id, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY);
let world = local.then(parent);
let class = s.class(id).unwrap_or(Tag(0));
let name = s.str(id, b"Desc").unwrap_or_default().to_string();
let visible = s.bool(id, b"Visi").unwrap_or(true);
let locked = s.bool(id, b"Edtb").is_some_and(|e| !e);
let opacity = s.f64(id, b"Opac").map_or(1.0, |o| o.clamp(0.0, 1.0));
let blend = match s.enumeration(id, b"Blnd") {
None if s.bool(id, b"PasT") != Some(false) && is_container(class) => Blend::PassThrough,
None => Blend::Normal,
Some((i, v)) => Blend::from_enum(i, v).unwrap_or_else(|| {
self.warn("an unknown blend mode (imported as Normal)");
Blend::Normal
}),
};
let kind = if !world.is_finite() {
self.warn("a layer with an invalid transform");
Kind::Unsupported
} else {
self.kind(id, class, world, parent)?
};
// A compound's children are its operands, already merged into its outline.
let (children, child_mask) = if class == Tag::of(b"Comp") { (Vec::new(), None) } else { self.children(id, world, depth)? };
let (mask, attached_mask) = self.attached_masks(id, world)?;
let pixel_mask = attached_mask.or(child_mask);
self.active.remove(&id);
if s.objs(id, b"FiEf").iter().any(|e| s.bool(*e, b"Enab") != Some(false)) {
self.warn("layer effects (shadows, glows, outlines, bevels…)");
}
Ok(Some(Node { class, name, visible, locked, opacity, blend, kind, mask, pixel_mask, children }))
}
fn kind(&mut self, id: ObjId, class: Tag, world: Affine, parent: Affine) -> Result<Kind, Error> {
let s = self.s;
Ok(match &class.0.to_be_bytes() {
b"Scop" => Kind::Layer,
b"Grup" => Kind::Group,
b"PCrv" | b"ShpN" | b"Comp" | b"SNEN" | b"SNRR" | b"ShRN" => {
if class == Tag::of(b"ShpN") && s.bool(id, b"ABEn") == Some(true) {
return Ok(self.artboard(id, world));
}
let Some((local, even_odd)) = crate::geometry::outline(self, id, class, world) else {
self.warn(format!("a {} that could not be read", describe(class)));
return Ok(Kind::Unsupported);
};
let (fills, strokes) = paint::node_paint(self, id, world);
Kind::Shape { path: local.transformed(world), fills, strokes, even_odd }
}
b"TxtA" | b"TxtF" | b"TxtC" => match crate::text::read(self, id, world) {
Some(t) => Kind::Text(t),
None => {
self.warn("text whose story could not be read (master-page text, or an unknown layout)");
Kind::Unsupported
}
},
b"ImgN" | b"Rstr" => match crate::raster::node_image(self, id, world)? {
Some(img) => Kind::Image(img),
None => Kind::Unsupported,
},
b"EmbN" => match crate::raster::embedded(self, id, world)? {
Some(img) => {
self.warn("embedded documents and symbols (imported as pictures of them)");
Kind::Image(img)
}
None => {
self.warn("embedded documents and symbols without a cached picture");
Kind::Unsupported
}
},
b"FRst" => {
// A fill layer paints its whole bitmap area; its gradient lives in page space.
let b = s.obj(id, b"Bitm");
let size = |t: &[u8; 4]| b.and_then(|b| s.int(b, t)).map(|v| v as f64).filter(|v| *v > 0.0);
match (size(b"BmpW"), size(b"BmpH")) {
(Some(w), Some(h)) => {
let local = Path { subpaths: vec![crate::shapes::rectangle(0.0, 0.0, w, h)] };
let fills = paint::fills(self, id, parent, b"BFFl");
Kind::Shape { path: local.transformed(world), fills, strokes: Vec::new(), even_odd: false }
}
_ => {
self.warn("fill layers without an area");
Kind::Unsupported
}
}
}
_ => {
self.warn(format!("{} layers", describe(class)));
Kind::Unsupported
}
})
}
/// Vector shapes attached to a node (`AdCh`) mask it: it shows only inside their outlines;
/// an attached mask layer is a pixel mask. Adjustments attached the same way are reported.
fn attached_masks(&mut self, id: ObjId, world: Affine) -> Result<(Option<Path>, Option<Image>), Error> {
let s = self.s;
let attached = s.objs(id, b"AdCh");
let mut mask: Option<Path> = None;
let mut pixels: Option<Image> = None;
for a in attached {
let class = s.class(a).unwrap_or(Tag(0));
if s.bool(a, b"Visi") == Some(false) {
continue;
}
if class == Tag::of(b"PCrv") || class == Tag::of(b"ShpN") || class == Tag::of(b"Comp") {
let local = s.floats::<6>(a, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY).then(world);
if let Some((p, _)) = crate::geometry::outline(self, a, class, local) {
mask.get_or_insert_with(Path::default).subpaths.extend(p.transformed(local).subpaths);
}
} else if class == Tag::of(b"MRst") {
let local = s.floats::<6>(a, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY).then(world);
match crate::raster::mask(self, a, local)? {
Some(m) if pixels.is_none() => pixels = Some(m),
Some(_) => self.warn("several pixel masks on one layer (only the first is used)"),
None => {}
}
} else {
self.warn(format!("{} layers attached to other layers (imported without them)", describe(class)));
}
}
Ok((mask, pixels))
}
fn artboard(&mut self, id: ObjId, world: Affine) -> Kind {
let s = self.s;
let b = s.floats::<4>(id, b"ShpB").map(rect).unwrap_or(Rect { x0: 0.0, y0: 0.0, x1: 0.0, y1: 0.0 });
let corners =
[Point { x: b.x0, y: b.y0 }, Point { x: b.x1, y: b.y0 }, Point { x: b.x1, y: b.y1 }, Point { x: b.x0, y: b.y1 }].map(|p| world.apply(p));
let [a, b2, c, d, ..] = world.0;
if b2.abs() > 1e-9 || c.abs() > 1e-9 || a <= 0.0 || d <= 0.0 {
self.warn("a rotated or flipped artboard (imported at its bounding box)");
}
let xs = corners.map(|p| p.x);
let ys = corners.map(|p| p.y);
let rect = Rect {
x0: xs.iter().copied().fold(f64::INFINITY, f64::min),
y0: ys.iter().copied().fold(f64::INFINITY, f64::min),
x1: xs.iter().copied().fold(f64::NEG_INFINITY, f64::max),
y1: ys.iter().copied().fold(f64::NEG_INFINITY, f64::max),
};
let background = paint::fills(self, id, world, b"BFFl");
Kind::Artboard { rect, background }
}
}
/// Page rectangles: `SpMd.PagR[].rctp` (Publisher 2, Affinity 3) or `SprB` split into `PagC`
/// equal pages (Publisher 1 facing spreads).
fn pages(s: &Stream, spread: ObjId) -> Vec<Rect> {
if let Some(md) = s.obj(spread, b"SpMd") {
let v: Vec<Rect> =
s.objs(md, b"PagR").into_iter().filter_map(|p| s.floats::<4>(p, b"rctp").map(rect)).filter(|r| r.x1 > r.x0 && r.y1 > r.y0).collect();
if !v.is_empty() {
return v;
}
}
match (s.floats::<4>(spread, b"SprB").map(rect), s.int(spread, b"PagC")) {
(Some(b), Some(n @ 2..=16)) => {
let w = (b.x1 - b.x0) / n as f64;
(0..n).map(|i| Rect { x0: b.x0 + w * i as f64, x1: b.x0 + w * (i + 1) as f64, ..b }).collect()
}
_ => Vec::new(),
}
}
pub(crate) fn union(a: Rect, b: Rect) -> Rect {
Rect { x0: a.x0.min(b.x0), y0: a.y0.min(b.y0), x1: a.x1.max(b.x1), y1: a.y1.max(b.y1) }
}
fn is_container(class: Tag) -> bool {
class == Tag::of(b"Scop") || class == Tag::of(b"Grup")
}
pub(crate) fn rect(v: [f64; 4]) -> Rect {
Rect { x0: v[0], y0: v[1], x1: v[2], y1: v[3] }
}
/// A readable name for a layer class in warnings.
pub(crate) fn describe(class: Tag) -> &'static str {
match &class.0.to_be_bytes() {
b"PCrv" => "curve",
b"ShpN" | b"SNEN" | b"SNRR" | b"ShRN" => "shape",
b"Comp" => "compound",
b"Rstr" => "pixel",
b"ImgN" => "image",
b"EmbN" => "embedded document",
b"MPIN" => "master page instance",
b"FRst" => "fill",
b"MRst" => "mask",
b"Slic" | b"SlcP" => "slice",
b"3DLA" => "3D",
[_, _, b'R', b'A'] => "adjustment",
_ => "unknown",
}
}
+329
View File
@@ -0,0 +1,329 @@
//! Fills, strokes and colours. Two schemas exist: Affinity 1 (`BFil` fill, `PFil` stroke paint,
//! `LSty` stroke style) and Affinity 2/3 (`BFFl`, `LIFl` and `LILn` lists with a current index).
use crate::model::{Affine, Reader};
use crate::stream::{ObjId, Value};
/// A colour as stored: RGB and grey values are encoded in the document's RGB space (sRGB unless
/// the document embeds another profile), CMYK values go through its CMYK profile. Alpha is straight.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Color {
Rgb {
r: f64,
g: f64,
b: f64,
a: f64,
},
Cmyk {
c: f64,
m: f64,
y: f64,
k: f64,
a: f64,
},
Gray {
v: f64,
a: f64,
},
/// CIE L*a*b* (D50).
Lab {
l: f64,
a: f64,
b: f64,
alpha: f64,
},
}
impl Color {
pub fn alpha(&self) -> f64 {
match self {
Color::Rgb { a, .. } | Color::Cmyk { a, .. } | Color::Gray { a, .. } => *a,
Color::Lab { alpha, .. } => *alpha,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GradientKind {
Linear,
/// Elliptical and radial: distance from the centre in unit space.
Radial,
/// Angle around the centre.
Conical,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Stop {
pub offset: f64,
pub color: Color,
/// Affinity's midpoint bias for the segment from this stop to the next (0.5 = even blend).
pub bias: f64,
}
impl Stop {
/// Where, between this stop (0) and the next (1), the blend reaches half way: the bias curve
/// fitted to Affinity's renders is `t^k` (bias ≥ 0.5) or `1-(1-t)^k`, with `k = 1 + 8·|bias-0.5|`.
pub fn half_point(&self) -> f64 {
let k = 1.0 + 8.0 * (self.bias - 0.5).abs();
let h = 0.5f64.powf(1.0 / k);
if self.bias >= 0.5 { h } else { 1.0 - h }
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Gradient {
pub kind: GradientKind,
pub stops: Vec<Stop>,
/// Unit space to document pixels: linear runs from unit (0,0) to (1,0); radial is the unit
/// circle; conical starts along unit +x and turns towards unit +y.
pub transform: Affine,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub enum Paint {
#[default]
None,
Solid(Color),
Gradient(Gradient),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Cap {
Butt,
Round,
Square,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Join {
Miter,
Round,
Bevel,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Align {
Center,
Inside,
Outside,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Stroke {
pub paint: Paint,
/// Width in document pixels.
pub width: f64,
pub cap: Cap,
pub join: Join,
pub miter_limit: f64,
pub align: Align,
/// Dash and gap lengths in document pixels, and the phase.
pub dash: Option<(Vec<f64>, f64)>,
/// Painted below the fill instead of above it.
pub behind: bool,
}
/// Gradient stops per gradient.
const MAX_STOPS: usize = 1024;
fn floats(v: &Value) -> Option<Vec<f64>> {
match v {
Value::Floats(f) => Some(f.clone()),
Value::Bytes(b) if b.len() % 4 == 0 => Some(b.chunks_exact(4).map(|c| f64::from(f32::from_le_bytes([c[0], c[1], c[2], c[3]]))).collect()),
_ => None,
}
}
/// A colour object (`RGBA`, `HSLA`, `CMYK`, `GRAY`, `LABA`, `Pant`).
pub(crate) fn color(r: &mut Reader, id: ObjId) -> Option<Color> {
let s = r.s;
let class = s.class(id)?;
let col = || -> Option<Vec<f64>> {
let v = floats(s.field(id, b"_col")?)?;
v.iter().all(|f| f.is_finite()).then_some(v)
};
Some(match &class.0.to_be_bytes() {
b"RGBA" => {
let v = col()?;
Color::Rgb { r: *v.first()?, g: *v.get(1)?, b: *v.get(2)?, a: v.get(3)?.clamp(0.0, 1.0) }
}
b"HSLA" => {
let v = col()?;
let (r2, g, b) = hsl(*v.first()?, *v.get(1)?, *v.get(2)?);
Color::Rgb { r: r2, g, b, a: v.get(3)?.clamp(0.0, 1.0) }
}
b"CMYK" => {
let v = col()?;
Color::Cmyk { c: *v.first()?, m: *v.get(1)?, y: *v.get(2)?, k: *v.get(3)?, a: v.get(4)?.clamp(0.0, 1.0) }
}
b"GRAY" => {
let v = col()?;
Color::Gray { v: *v.first()?, a: v.get(1)?.clamp(0.0, 1.0) }
}
b"LABA" => {
let b = s.bytes(id, b"_col")?;
let u = |i: usize| -> Option<f64> { Some(f64::from(u16::from_le_bytes([*b.get(2 * i)?, *b.get(2 * i + 1)?])) / 65535.0) };
Color::Lab { l: 100.0 * u(0)?, a: 255.0 * u(1)? - 128.0, b: 255.0 * u(2)? - 128.0, alpha: u(3)? }
}
b"Pant" => {
r.warn("spot colours (imported as their RGB equivalent)");
return s.obj(id, b"srgb").and_then(|c| color(r, c)).or_else(|| s.obj(id, b"base").and_then(|c| color(r, c)));
}
b"RegC" => Color::Cmyk { c: 1.0, m: 1.0, y: 1.0, k: 1.0, a: 1.0 },
_ => {
r.warn("colours in an unknown model");
return None;
}
})
}
fn hsl(h: f64, s: f64, l: f64) -> (f64, f64, f64) {
let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
let hp = (h.rem_euclid(1.0)) * 6.0;
let x = c * (1.0 - (hp % 2.0 - 1.0).abs());
let (r, g, b) = match hp as u32 {
0 => (c, x, 0.0),
1 => (x, c, 0.0),
2 => (0.0, c, x),
3 => (0.0, x, c),
4 => (x, 0.0, c),
_ => (c, 0.0, x),
};
let m = l - c / 2.0;
(r + m, g + m, b + m)
}
/// A fill descriptor (`FDsc`) or, in Affinity 1 files, a bare `FilS`; `local` maps the node's
/// space to document pixels.
pub(crate) fn descriptor(r: &mut Reader, id: ObjId, local: Affine) -> Option<Paint> {
let s = r.s;
let (fill, unit) = if s.is(id, b"FDsc") {
let unit = s.floats::<6>(id, b"FDeX").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY);
(s.obj(id, b"FDeF")?, unit)
} else {
(id, Affine::IDENTITY)
};
match &s.class(fill)?.0.to_be_bytes() {
b"FilN" => Some(Paint::None),
b"FilS" => Some(Paint::Solid(color(r, s.obj(fill, b"Colr")?)?)),
b"FilG" => {
let kind = match s.enumeration(fill, b"Type").map(|(i, _)| i) {
Some(0) | None => GradientKind::Linear,
Some(1 | 2) => GradientKind::Radial,
Some(3) => GradientKind::Conical,
Some(_) => {
r.warn("gradients of an unknown kind (imported as linear)");
GradientKind::Linear
}
};
let grad = s.obj(fill, b"Grad")?;
let cols = s.objs(grad, b"Cols");
let posn: Vec<Vec<f64>> = match s.field(grad, b"Posn") {
Some(Value::Array(a)) => a.iter().filter_map(floats).collect(),
_ => Vec::new(),
};
let mut stops = Vec::new();
for (c, p) in cols.iter().zip(&posn).take(MAX_STOPS) {
let color = color(r, *c)?;
let offset = p.first().copied().filter(|v| v.is_finite()).unwrap_or(0.0).clamp(0.0, 1.0);
let bias = p.get(1).copied().filter(|v| v.is_finite()).unwrap_or(0.5).clamp(0.0, 1.0);
stops.push(Stop { offset, color, bias });
}
if stops.is_empty() {
return Some(Paint::None);
}
Some(Paint::Gradient(Gradient { kind, stops, transform: unit.then(local) }))
}
b"FilB" => {
r.warn("bitmap and pattern fills (left empty)");
Some(Paint::None)
}
_ => {
r.warn("fills of an unknown kind (left empty)");
Some(Paint::None)
}
}
}
fn current(r: &Reader, id: ObjId, list: &[u8; 4], index: &[u8; 4]) -> Option<ObjId> {
let items = r.s.objs(id, list);
let i = r.s.int(id, index).and_then(|i| usize::try_from(i).ok()).unwrap_or(0);
items.get(i).or_else(|| items.first()).copied()
}
/// The fills of `list` (artboard backgrounds use `BFFl`).
pub(crate) fn fills(r: &mut Reader, id: ObjId, local: Affine, list: &[u8; 4]) -> Vec<crate::paint::Paint> {
let d = current(r, id, list, b"BFCr").or_else(|| r.s.obj(id, b"BFil"));
d.and_then(|d| descriptor(r, d, local)).filter(|p| *p != Paint::None).into_iter().collect()
}
/// A node's fill and stroke. `local` maps the node's space to document pixels.
pub(crate) fn node_paint(r: &mut Reader, id: ObjId, local: Affine) -> (Vec<Paint>, Vec<Stroke>) {
let s = r.s;
let fills = fills(r, id, local, b"BFFl");
if r.s.objs(id, b"BFFl").len() > 1 || r.s.objs(id, b"LIFl").len() > 1 {
r.warn("several fills or strokes on one object (only the active one is imported)");
}
if s.f64(id, b"FOpc").is_some_and(|o| o < 0.999) {
r.warn("fill opacity (imported at full fill opacity)");
}
if s.obj(id, b"Trns").is_some() {
r.warn("transparency gradients (imported without them)");
}
let (pen, style) = if s.field(id, b"LIFl").is_some() {
(current(r, id, b"LIFl", b"LICr"), current(r, id, b"LILn", b"LICr"))
} else {
(s.obj(id, b"PFil"), s.obj(id, b"LSty"))
};
let behind = matches!(s.field(id, b"DrwO"), Some(Value::Array(a)) if a.first() == Some(&Value::Enum { id: 1, version: 0 }));
let stroke = match (pen, style) {
(Some(pen), Some(style)) => stroke(r, pen, style, local, behind),
_ => None,
};
(fills, stroke.into_iter().collect())
}
fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, behind: bool) -> Option<Stroke> {
let s = r.s;
let paint = descriptor(r, pen, local)?;
if paint == Paint::None {
return None;
}
let lsty = if s.is(ldsc, b"LDsc") { s.obj(ldsc, b"LDeL")? } else { ldsc };
let weight = s.f64(lsty, b"Wght").filter(|w| *w > 0.0 && *w < 1e7)?;
// "Scale with object": the weight follows the object's transform; otherwise it is absolute.
let scales = s.bool(ldsc, b"LDSc").unwrap_or(true);
let width = if scales { weight * local.scale() } else { weight };
let data = s.bytes(lsty, b"Data").unwrap_or_default();
let miter_limit = data.get(0..8).and_then(|b| b.try_into().ok()).map(f64::from_le_bytes).filter(|m| m.is_finite() && *m >= 1.0).unwrap_or(4.0);
// Cap and join bytes are only partly understood: (0, 0) is butt/miter in every clean sample;
// (2, 2) ends plain lines square and brush lines round; joins import round, which never
// looks wrong.
let (cap, join) = match (data.get(8).copied(), data.get(9).copied(), data.get(10).copied()) {
(Some(0), Some(0), _) => (Cap::Butt, Join::Miter),
(Some(0), Some(_), _) => (Cap::Butt, Join::Round),
(Some(2), Some(2), Some(3)) => (Cap::Round, Join::Round),
(Some(2), Some(2), _) => (Cap::Square, Join::Round),
_ => (Cap::Round, Join::Round),
};
let align = match s.int(ldsc, b"LDSa") {
Some(1) => Align::Inside,
Some(2) => Align::Outside,
_ => Align::Center,
};
let dash = match s.field(lsty, b"Patn") {
Some(Value::Array(a)) if data.get(10) == Some(&2) || !a.is_empty() => {
let mut v: Vec<f64> = a.iter().filter_map(|x| if let Value::Float(f) = x { Some(*f) } else { None }).collect();
while v.len() >= 2 && v.iter().rev().take(2).all(|x| *x == 0.0) {
v.truncate(v.len() - 2);
}
let valid = !v.is_empty() && v.iter().all(|x| x.is_finite() && *x >= 0.0) && v.iter().any(|x| *x > 0.0);
valid.then(|| (v.iter().map(|x| x * width).collect(), s.f64(lsty, b"Phse").unwrap_or(0.0) * width))
}
_ => None,
};
if s.obj(lsty, b"Brus").is_some() || s.obj(ldsc, b"LDeP").is_some() {
r.warn("brush and pressure strokes (imported as plain strokes)");
}
Some(Stroke { paint, width, cap, join, miter_limit, align, dash, behind })
}
+127
View File
@@ -0,0 +1,127 @@
//! The indexed PNG thumbnail Affinity stores next to the archive: Affinity's own render of the
//! document, at most 512 pixels on the long side in real files, never the document itself.
use crate::Error;
use crate::container::header;
pub const MAX_PREVIEW_BYTES: usize = 16 << 20;
pub const MAX_PREVIEW_DIMENSION: u32 = 4096;
/// Borrowed embedded thumbnail, at its own dimensions, never the native document dimensions.
#[derive(Debug)]
pub struct Preview<'a> {
pub png: &'a [u8],
pub width: u32,
pub height: u32,
}
fn bytes_at(bytes: &[u8], offset: usize, length: usize) -> Result<&[u8], Error> {
let end = offset.checked_add(length).ok_or(Error::Malformed("offset overflow"))?;
bytes.get(offset..end).ok_or(Error::Malformed("truncated record"))
}
fn le32(bytes: &[u8], offset: usize) -> Result<u32, Error> {
let b = bytes_at(bytes, offset, 4)?;
Ok(u32::from_le_bytes(b.try_into().map_err(|_| Error::Malformed("integer"))?))
}
fn be32(bytes: &[u8], offset: usize) -> Result<u32, Error> {
let b = bytes_at(bytes, offset, 4)?;
Ok(u32::from_be_bytes(b.try_into().map_err(|_| Error::Malformed("PNG integer"))?))
}
/// Read only the indexed `Thmb` record (identical in container versions 8 to 12), checking every PNG chunk CRC. Compressed
/// pixels must still be validated by the consuming image decoder, with allocation limits.
pub fn preview(bytes: &[u8]) -> Result<Preview<'_>, Error> {
let h = header(bytes)?;
if h.thumbnail_offset == 0 {
return Err(Error::Unsupported("no embedded preview"));
}
let offset = usize::try_from(h.thumbnail_offset).map_err(|_| Error::Malformed("thumbnail offset overflow"))?;
if offset < 72 {
return Err(Error::Malformed("thumbnail overlaps the file header"));
}
let record = bytes.get(offset..).ok_or(Error::Malformed("thumbnail offset outside file"))?;
if bytes_at(record, 0, 8)? != b"\xff\xff\xff\xffThmb" {
return Err(Error::Malformed("missing indexed thumbnail"));
}
if le32(record, 8)? != 1 || bytes_at(record, 28, 1)? != [1] {
return Err(Error::Unsupported("thumbnail record version or encoding"));
}
let size = usize::try_from(le32(record, 24)?).map_err(|_| Error::Limit("encoded size"))?;
if size > MAX_PREVIEW_BYTES {
return Err(Error::Limit("encoded preview exceeds 16 MiB"));
}
if le32(record, 16)? != 29 || le32(record, 20)? != 0 || u64::from(le32(record, 12)?) != size as u64 + 13 {
return Err(Error::Malformed("inconsistent thumbnail record lengths"));
}
let png = bytes_at(record, 29, size)?;
if bytes_at(png, 0, 8)? != b"\x89PNG\r\n\x1a\n" || be32(png, 8)? != 13 || bytes_at(png, 12, 4)? != b"IHDR" {
return Err(Error::Malformed("thumbnail is not a PNG"));
}
let width = be32(png, 16)?;
let height = be32(png, 20)?;
if width == 0 || height == 0 || width > MAX_PREVIEW_DIMENSION || height > MAX_PREVIEW_DIMENSION {
return Err(Error::Limit("preview dimensions must be 1..4096"));
}
// Bound work by bytes, not a file-supplied chunk count, and require exactly one complete PNG.
let mut pos = 8usize;
let (mut palette, mut image_data, mut image_data_ended) = (false, false, false);
loop {
let length = usize::try_from(be32(png, pos)?).map_err(|_| Error::Limit("PNG chunk length"))?;
let chunk_size = length.checked_add(12).ok_or(Error::Malformed("PNG chunk overflow"))?;
let chunk = bytes_at(png, pos, chunk_size)?;
let checksum_input = bytes_at(chunk, 4, chunk_size - 8)?;
if crc32fast::hash(checksum_input) != be32(chunk, chunk_size - 4)? {
return Err(Error::Malformed("PNG chunk checksum"));
}
let kind = bytes_at(chunk, 4, 4)?;
if !kind.iter().all(u8::is_ascii_alphabetic) {
return Err(Error::Malformed("PNG chunk type"));
}
match kind {
b"IHDR" if pos == 8 => {}
b"IHDR" => return Err(Error::Malformed("duplicate PNG header")),
b"PLTE" => {
if palette || image_data {
return Err(Error::Malformed("PNG palette order"));
}
palette = true;
}
b"IDAT" => {
if image_data_ended {
return Err(Error::Malformed("non-contiguous PNG image data"));
}
image_data = true;
}
b"IEND" => {
if !image_data {
return Err(Error::Malformed("PNG has no image data"));
}
}
_ if kind.first().is_some_and(u8::is_ascii_uppercase) => {
return Err(Error::Unsupported("unknown critical PNG chunk"));
}
_ => {}
}
if image_data && kind != b"IDAT" {
image_data_ended = true;
}
// Native sample previews are plain, single-frame PNGs. Pixel allocation limits do not
// necessarily bound aggregate compressed metadata; leave these layouts unsupported.
if matches!(kind, b"zTXt" | b"iTXt" | b"iCCP") {
return Err(Error::Unsupported("compressed PNG metadata in the preview"));
}
if matches!(kind, b"acTL" | b"fcTL" | b"fdAT") {
return Err(Error::Unsupported("animated PNG preview"));
}
pos = pos.checked_add(chunk_size).ok_or(Error::Malformed("PNG offset overflow"))?;
if kind == b"IEND" {
if length != 0 || pos != png.len() {
return Err(Error::Malformed("PNG end or trailing data"));
}
break;
}
}
Ok(Preview { png, width, height })
}
+243
View File
@@ -0,0 +1,243 @@
//! Pixel layers (`Rstr`) and placed images (`ImgN`). A placed image keeps its original file
//! (`DyBm.Bckg` → archive entry `c/N`), which is imported as is; other pixels are planar tiles of
//! 256 bytes × 256 rows per channel, each its own archive entry.
use std::collections::HashMap;
use crate::Error;
use crate::model::{Affine, Image, Pixels, Reader};
use crate::stream::{self, ObjId, Tag, Value};
/// Pixels decoded per layer (the content's bounding box, not the canvas).
const MAX_PIXELS: u64 = 64 << 20;
const TILE: usize = 256;
const TILE_BYTES: usize = TILE * TILE;
pub(crate) fn node_image(r: &mut Reader, id: ObjId, world: Affine) -> Result<Option<Image>, Error> {
let s = r.s;
let Some(bitmap) = s.obj(id, b"Bitm") else {
r.warn("pixel layers without pixels");
return Ok(None);
};
if !s.is(bitmap, b"DyBm") {
r.warn("pixel layers stored in an unknown way");
return Ok(None);
}
let width = s.int(bitmap, b"BmpW").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
let height = s.int(bitmap, b"BmpH").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
if width == 0 || height == 0 {
return Ok(None);
}
// A placed image: the original file, byte for byte.
let has_tiles = s.field(bitmap, b"Sta1").is_some();
if let Some(name) = s.entry(bitmap, b"Bckg").filter(|n| !n.is_empty())
&& (s.is(id, b"ImgN") || !has_tiles)
{
match original(r, name) {
Ok(bytes) => return Ok(Some(Image { width, height, pixels: Pixels::Encoded(bytes), transform: world })),
Err(Error::Limit(e)) => return Err(Error::Limit(e)),
Err(_) => r.warn("an embedded image file that could not be read (its cached pixels were used)"),
}
}
// The content's bounding box, so a mostly empty canvas-sized layer stays small.
let crop = s
.field(id, b"BitR")
.and_then(|v| if let Value::Ints(i) = v { Some(i.clone()) } else { None })
.filter(|v| v.len() == 4 && v[0] > i64::from(i32::MIN) + 1)
.map(|v| {
(
v[0].clamp(0, i64::from(width)) as u32,
v[1].clamp(0, i64::from(height)) as u32,
v[2].clamp(0, i64::from(width)) as u32,
v[3].clamp(0, i64::from(height)) as u32,
)
})
.unwrap_or((0, 0, width, height));
if s.field(bitmap, b"Bckg").is_some() && has_tiles && !s.is(id, b"ImgN") {
r.warn("edited placed images use their stored pixels");
}
decode(r, bitmap, crop, world)
}
/// A mask layer (`MRst`): one channel of coverage as grey pixels (white shows, black hides).
pub(crate) fn mask(r: &mut Reader, id: ObjId, world: Affine) -> Result<Option<Image>, Error> {
let s = r.s;
let Some(bitmap) = s.obj(id, b"Bitm") else { return Ok(None) };
let width = s.int(bitmap, b"BmpW").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
let height = s.int(bitmap, b"BmpH").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
if width == 0 || height == 0 || !matches!(s.enumeration(bitmap, b"Frmt"), Some((6 | 7, _))) {
r.warn("pixel masks in an unknown format (imported without them)");
return Ok(None);
}
decode(r, bitmap, (0, 0, width, height), world)
}
/// An embedded document or symbol instance (`EmbN`), as the picture of it Affinity caches.
pub(crate) fn embedded(r: &mut Reader, id: ObjId, world: Affine) -> Result<Option<Image>, Error> {
let s = r.s;
let Some(cache) = s.obj(id, b"Bitm").and_then(|e| s.obj(e, b"Cach")) else { return Ok(None) };
let Some(bbox) = s.obj(id, b"Bitm").and_then(|e| s.floats::<4>(e, b"ChBB")) else { return Ok(None) };
let width = s.int(cache, b"BmpW").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
let height = s.int(cache, b"BmpH").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
if width == 0 || height == 0 {
return Ok(None);
}
let [x0, y0, x1, y1] = bbox;
// The instance's origin is the centre of the embedded content (checked against the
// thumbnails of documents with embedded documents and SVG files).
let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
let place = Affine([(x1 - x0) / f64::from(width), 0.0, 0.0, (y1 - y0) / f64::from(height), x0 - cx, y0 - cy]).then(world);
decode(r, cache, (0, 0, width, height), place)
}
/// Decode the tiles of a bitmap inside `crop` (pixels), placed by `world` (bitmap pixels to document).
fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affine) -> Result<Option<Image>, Error> {
let s = r.s;
let (x0, y0, x1, y1) = crop;
if x1 <= x0 || y1 <= y0 {
return Ok(None);
}
let (w, h) = (x1 - x0, y1 - y0);
if u64::from(w) * u64::from(h) > MAX_PIXELS {
r.warn("pixel layers larger than 64 megapixels");
return Ok(None);
}
let format = s.enumeration(bitmap, b"Frmt").map(|(f, _)| f);
let (channels, bps) = match format {
Some(0) => (4, 1),
Some(1) => (4, 2),
Some(6) => (1, 1),
Some(7) => (1, 2),
Some(4) => {
r.warn("CMYK pixel layers (converted to RGB without the document's colour profile)");
(5, 1)
}
_ => {
r.warn("pixel layers in grey, Lab or 32-bit formats");
return Ok(None);
}
};
let mut planes = Vec::with_capacity(channels);
let mut cache = HashMap::new();
for c in 1..=channels {
planes.push(plane(r, bitmap, c, bps, (x0, y0, w, h), &mut cache)?);
}
let n = (w as usize) * (h as usize);
let mut rgba = vec![0u8; n * 4];
let sample = |p: &Vec<u8>, i: usize| -> f64 {
if bps == 2 {
f64::from(u16::from_le_bytes([p.get(2 * i).copied().unwrap_or(0), p.get(2 * i + 1).copied().unwrap_or(0)])) / 65535.0
} else {
f64::from(p.get(i).copied().unwrap_or(0)) / 255.0
}
};
for (i, px) in rgba.chunks_exact_mut(4).enumerate() {
let v: Vec<f64> = planes.iter().map(|p| sample(p, i)).collect();
let (r2, g, b, a) = if channels == 1 {
(v[0], v[0], v[0], 1.0)
} else if channels == 5 {
let k = 1.0 - v[3];
((1.0 - v[0]) * k, (1.0 - v[1]) * k, (1.0 - v[2]) * k, v[4])
} else {
(v[0], v[1], v[2], v[3])
};
px.copy_from_slice(&[r2, g, b, a].map(|c| (c.clamp(0.0, 1.0) * 255.0).round() as u8));
}
let transform = Affine([1.0, 0.0, 0.0, 1.0, f64::from(x0), f64::from(y0)]).then(world);
Ok(Some(Image { width: w, height: h, pixels: Pixels::Rgba8(rgba), transform }))
}
/// The original image file: entry `c/N` is a stream whose root (`Blck`) holds it as `Data`.
fn original(r: &mut Reader, name: &str) -> Result<Vec<u8>, Error> {
let bytes = r.archive.read(name)?;
let s = stream::parse(&bytes)?;
let root = s.object(s.root).ok_or(Error::Malformed("embedded image"))?;
match root.get(Tag::of(b"Data")) {
Some(Value::Blob(b)) if !b.is_empty() => Ok(b.clone()),
_ => Err(Error::Malformed("embedded image without data")),
}
}
fn tag(prefix: &[u8; 3], c: usize) -> [u8; 4] {
[prefix[0], prefix[1], prefix[2], b'0' + c as u8]
}
/// One channel of level 0, cropped to (x0, y0, w, h) pixels; `bps` bytes per sample.
fn plane(
r: &mut Reader,
bitmap: ObjId,
c: usize,
bps: usize,
(x0, y0, w, h): (u32, u32, u32, u32),
cache: &mut HashMap<String, Vec<u8>>,
) -> Result<Vec<u8>, Error> {
let s = r.s;
let width = s.int(bitmap, b"BmpW").and_then(|v| usize::try_from(v).ok()).unwrap_or(0);
let height = s.int(bitmap, b"BmpH").and_then(|v| usize::try_from(v).ok()).unwrap_or(0);
let tw = s.int(bitmap, &tag(b"TWi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| (width * bps).div_ceil(TILE));
let th = s.int(bitmap, &tag(b"THi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| height.div_ceil(TILE));
let states: Vec<u8> = match s.field(bitmap, &tag(b"Sta", c)) {
Some(Value::Array(a)) => a.iter().map(|v| if let Value::UInt(u) = v { u8::try_from(*u).unwrap_or(255) } else { 255 }).collect(),
_ => Vec::new(),
};
let tiles = s.objs(bitmap, &tag(b"Idx", c));
let row = (w as usize) * bps;
let mut out = vec![0u8; row * h as usize];
let (bx0, bx1) = (x0 as usize * bps, (x0 + w) as usize * bps);
let (py0, py1) = (y0 as usize, (y0 + h) as usize);
let mut next = 0usize;
for (t, state) in states.iter().enumerate().take(tw.saturating_mul(th)) {
let (ty, tx) = (t / tw.max(1), t % tw.max(1));
let fill = match state {
0 | 1 => None,
2 => Some(0xFF),
4 => {
let Some(blck) = tiles.get(next).copied() else { return Err(Error::Malformed("missing pixel tile")) };
next += 1;
let (ox, oy) = (tx * TILE, ty * TILE);
if ox >= bx1 || ox + TILE <= bx0 || oy >= py1 || oy + TILE <= py0 {
continue;
}
let name = s.entry(blck, b"Data").ok_or(Error::Malformed("pixel tile without data"))?.to_string();
if !cache.contains_key(&name) {
let mut data = r.archive.read(&name)?;
// Older files wrap a tile in a one-field stream: header, then a 64 KiB blob.
if data.len() == TILE_BYTES + 22 && data.starts_with(b"\x00\xffKS") {
data = data.get(21..21 + TILE_BYTES).ok_or(Error::Malformed("pixel tile"))?.to_vec();
}
if data.len() != TILE_BYTES {
return Err(Error::Malformed("pixel tile size"));
}
cache.insert(name.clone(), data);
}
let tile = cache.get(&name).ok_or(Error::Malformed("pixel tile"))?;
copy(&mut out, row, (bx0, py0, bx1, py1), (ox, oy), |x, y| tile.get(y * TILE + x).copied().unwrap_or(0));
continue;
}
3 => {
r.warn("32-bit pixel layers");
None
}
5 => {
r.warn("pixel layers drawn from an embedded image (left empty)");
None
}
_ => return Err(Error::Malformed("unknown pixel tile state")),
};
if let Some(v) = fill {
copy(&mut out, row, (bx0, py0, bx1, py1), (tx * TILE, ty * TILE), |_, _| v);
}
}
Ok(out)
}
/// Copy the overlap of a tile at byte column `ox`, row `oy` into the cropped plane.
fn copy(out: &mut [u8], row: usize, (bx0, py0, bx1, py1): (usize, usize, usize, usize), (ox, oy): (usize, usize), at: impl Fn(usize, usize) -> u8) {
for y in oy.max(py0)..(oy + TILE).min(py1) {
for x in ox.max(bx0)..(ox + TILE).min(bx1) {
if let Some(b) = out.get_mut((y - py0) * row + (x - bx0)) {
*b = at(x - ox, y - oy);
}
}
}
}
+259
View File
@@ -0,0 +1,259 @@
//! Parametric shapes: Affinity stores the shape's parameters and its box, never the outline.
//! Formulas were fitted to Affinity's own renders (the document thumbnails) of public files.
use std::f64::consts::{FRAC_PI_2, PI, TAU};
use crate::model::{Affine, Path, Point, Reader, SubPath};
use crate::stream::{ObjId, Tag, Value};
/// Handle length of a quarter circle of radius 1.
const KAPPA: f64 = 0.552_284_749_830_793_4;
/// Vertices of a polygon or star.
const MAX_SIDES: i64 = 10_000;
/// The last value of a tag: a shape changed from one kind to another keeps a stale first block.
fn last<'a>(r: &'a Reader, id: ObjId, tag: &[u8; 4]) -> Option<&'a Value> {
let t = Tag::of(tag);
r.s.object(id)?.fields.iter().rev().find(|(k, _)| *k == t).map(|(_, v)| v)
}
fn num(r: &Reader, id: ObjId, tag: &[u8; 4]) -> Option<f64> {
match last(r, id, tag)? {
Value::Float(f) if f.is_finite() => Some(*f),
Value::UInt(u) => Some(*u as f64),
Value::Int(i) => Some(*i as f64),
_ => None,
}
}
fn sides(r: &Reader, id: ObjId, tag: &[u8; 4], default: f64) -> usize {
let n = num(r, id, tag).unwrap_or(default).round();
n.clamp(3.0, MAX_SIDES as f64) as usize
}
fn pt(x: f64, y: f64) -> Point {
Point { x, y }
}
fn polygon(pts: impl IntoIterator<Item = Point>) -> Option<SubPath> {
let mut it = pts.into_iter();
let start = it.next()?;
let segments = it.map(|p| [p, p, p]).collect();
Some(SubPath { start, segments, closed: true })
}
/// Outline of `shape` (a `Shpe` object) in the node box `b` = (x0, y0, x1, y1).
pub(crate) fn shape(r: &mut Reader, shape: ObjId, b: [f64; 4], world: Affine) -> Option<Path> {
let [x0, y0, x1, y1] = b;
let (w, h) = (x1 - x0, y1 - y0);
let (cx, cy, rx, ry) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0, w / 2.0, h / 2.0);
let on_ellipse = |a: f64, f: f64| pt(cx + f * rx * a.cos(), cy + f * ry * a.sin());
let class = r.s.class(shape)?;
let sub = match &class.0.to_be_bytes() {
b"ShNR" | b"ShRR" => {
let rounded = matches!(last(r, shape, b"CTyp"), Some(Value::Array(a)) if !a.is_empty());
let radius = match last(r, shape, b"ShCR") {
Some(Value::Floats(v)) => v.first().copied().filter(|f| f.is_finite() && *f > 0.0),
_ => None,
};
if let (true, Some(f)) = (rounded, radius) {
if let Some(Value::Array(types)) = last(r, shape, b"CTyp")
&& types.iter().any(|t| !matches!(t, Value::Enum { id: 0 | 4, .. }))
{
r.warn("rectangles with straight, concave or other corner types (imported with round corners)");
}
let (crx, cry) = if r.s.bool(shape, b"AbSz") == Some(true) {
// An absolute radius is in document pixels, whatever the node's scale.
let [a, b2, c, d, ..] = world.0;
let (sx, sy) = ((a * a + b2 * b2).sqrt(), (c * c + d * d).sqrt());
if sx > 0.0 && sy > 0.0 { (f / sx, f / sy) } else { (0.0, 0.0) }
} else {
(f * w.min(h), f * w.min(h))
};
rounded_rect(b, crx.min(w / 2.0), cry.min(h / 2.0))
} else {
polygon([pt(x0, y0), pt(x1, y0), pt(x1, y1), pt(x0, y1)])?
}
}
b"ShpE" => ellipse(cx, cy, rx, ry),
b"ShPy" => {
let n = sides(r, shape, b"Side", 5.0);
let a = |i: usize| -FRAC_PI_2 + TAU * i as f64 / n as f64;
if r.s.object(shape).is_some_and(|_| matches!(last(r, shape, b"Smth"), Some(Value::Bool(true)))) {
// Smoothed: tangent handles of length curv·4/3·tan(π/2n) along the inscribed ellipse.
let k = num(r, shape, b"Curv").unwrap_or(0.0) * 4.0 / 3.0 * (PI / (2.0 * n as f64)).tan();
let v = |i: usize| on_ellipse(a(i), 1.0);
let t = |i: usize| pt(-rx * a(i).sin(), ry * a(i).cos());
let segments = (0..n)
.map(|i| {
let (p, q, tp, tq) = (v(i), v(i + 1), t(i), t(i + 1));
[pt(p.x + k * tp.x, p.y + k * tp.y), pt(q.x - k * tq.x, q.y - k * tq.y), q]
})
.collect();
SubPath { start: v(0), segments, closed: true }
} else {
polygon((0..n).map(|i| on_ellipse(a(i), 1.0)))?
}
}
b"ShSt" => {
let n = sides(r, shape, b"Pnts", 5.0);
let q = num(r, shape, b"IRad").unwrap_or(0.382).clamp(0.0, 1.0);
if [b"CrcI", b"CrcO", b"CrvL", b"CrvR"].iter().any(|t| num(r, shape, t).is_some_and(|v| v.abs() > 1e-9)) {
r.warn("stars with rounded or curved points (imported with sharp points)");
}
polygon((0..2 * n).map(|i| on_ellipse(-FRAC_PI_2 + PI * i as f64 / n as f64, if i % 2 == 0 { 1.0 } else { q })))?
}
b"ShSS" => {
let n = sides(r, shape, b"Side", 5.0);
let ri = num(r, shape, b"COut").unwrap_or(0.5).clamp(0.0, 1.0);
let step = PI / n as f64;
let (l, hw, inner) = (step.cos(), ri * step.sin(), ri * step.cos());
polygon((0..n).flat_map(|k| {
let phi = -FRAC_PI_2 + step + TAU * k as f64 / n as f64;
let (c, s) = (phi.cos(), phi.sin());
[(inner, -hw), (l, -hw), (l, hw), (inner, hw)].map(|(u, v)| pt(cx + rx * (u * c - v * s), cy + ry * (u * s + v * c)))
}))?
}
b"ShPi" => {
let start = num(r, shape, b"AngS").unwrap_or(0.0);
let mut end = num(r, shape, b"AngE").unwrap_or(TAU);
while end < start {
end += TAU;
}
let inner = num(r, shape, b"IRad").unwrap_or(0.0).clamp(0.0, 1.0);
pie(cx, cy, rx, ry, start, end.min(start + TAU), inner)
}
b"ShpT" => {
let p = num(r, shape, b"Pos ").unwrap_or(0.5);
polygon([pt(x0 + p * w, y0), pt(x1, y1), pt(x0, y1)])?
}
b"ShTz" => {
let (l, rr) = (num(r, shape, b"PosL").unwrap_or(0.25), num(r, shape, b"PosR").unwrap_or(0.75));
polygon([pt(x0 + l * w, y0), pt(x0 + rr * w, y0), pt(x1, y1), pt(x0, y1)])?
}
_ => {
r.warn("cloud, heart, cog, callout, arrow, tear and other special shapes (imported as their bounding ellipse)");
ellipse(cx, cy, rx, ry)
}
};
Some(Path { subpaths: vec![sub] })
}
pub(crate) fn rectangle(x0: f64, y0: f64, x1: f64, y1: f64) -> SubPath {
SubPath { start: pt(x0, y0), segments: [pt(x1, y0), pt(x1, y1), pt(x0, y1)].map(|p| [p, p, p]).to_vec(), closed: true }
}
fn ellipse(cx: f64, cy: f64, rx: f64, ry: f64) -> SubPath {
let (kx, ky) = (KAPPA * rx, KAPPA * ry);
SubPath {
start: pt(cx + rx, cy),
segments: vec![
[pt(cx + rx, cy + ky), pt(cx + kx, cy + ry), pt(cx, cy + ry)],
[pt(cx - kx, cy + ry), pt(cx - rx, cy + ky), pt(cx - rx, cy)],
[pt(cx - rx, cy - ky), pt(cx - kx, cy - ry), pt(cx, cy - ry)],
[pt(cx + kx, cy - ry), pt(cx + rx, cy - ky), pt(cx + rx, cy)],
],
closed: true,
}
}
fn rounded_rect([x0, y0, x1, y1]: [f64; 4], rx: f64, ry: f64) -> SubPath {
let (kx, ky) = ((1.0 - KAPPA) * rx, (1.0 - KAPPA) * ry);
SubPath {
start: pt(x0 + rx, y0),
segments: vec![
[pt(x1 - rx, y0), pt(x1 - rx, y0), pt(x1 - rx, y0)],
[pt(x1 - kx, y0), pt(x1, y0 + ky), pt(x1, y0 + ry)],
[pt(x1, y1 - ry), pt(x1, y1 - ry), pt(x1, y1 - ry)],
[pt(x1, y1 - ky), pt(x1 - kx, y1), pt(x1 - rx, y1)],
[pt(x0 + rx, y1), pt(x0 + rx, y1), pt(x0 + rx, y1)],
[pt(x0 + kx, y1), pt(x0, y1 - ky), pt(x0, y1 - ry)],
[pt(x0, y0 + ry), pt(x0, y0 + ry), pt(x0, y0 + ry)],
[pt(x0, y0 + ky), pt(x0 + kx, y0), pt(x0 + rx, y0)],
],
closed: true,
}
}
/// Elliptical arc from angle `a0` to `a1` (radians, measured y-up as Affinity's pie angles are),
/// as cubic segments of at most a quarter turn.
fn arc(cx: f64, cy: f64, rx: f64, ry: f64, a0: f64, a1: f64, out: &mut Vec<[Point; 3]>) {
let n = ((a1 - a0).abs() / FRAC_PI_2).ceil().clamp(1.0, 8.0) as usize;
let step = (a1 - a0) / n as f64;
let k = 4.0 / 3.0 * (step / 4.0).tan();
let p = |a: f64| pt(cx + rx * a.cos(), cy - ry * a.sin());
let d = |a: f64| pt(-rx * a.sin(), -ry * a.cos());
for i in 0..n {
let (s, e) = (a0 + step * i as f64, a0 + step * (i + 1) as f64);
let (ps, pe, ds, de) = (p(s), p(e), d(s), d(e));
out.push([pt(ps.x + k * ds.x, ps.y + k * ds.y), pt(pe.x - k * de.x, pe.y - k * de.y), pe]);
}
}
fn pie(cx: f64, cy: f64, rx: f64, ry: f64, a0: f64, a1: f64, inner: f64) -> SubPath {
let start = pt(cx + rx * a0.cos(), cy - ry * a0.sin());
let mut segments = Vec::new();
arc(cx, cy, rx, ry, a0, a1, &mut segments);
if inner > 0.0 {
let p = pt(cx + inner * rx * a1.cos(), cy - inner * ry * a1.sin());
segments.push([p, p, p]);
arc(cx, cy, inner * rx, inner * ry, a1, a0, &mut segments);
} else {
let c = pt(cx, cy);
segments.push([c, c, c]);
}
SubPath { start, segments, closed: true }
}
/// A polyline with circular corners of the given radii (Affinity's live corners on curves).
pub(crate) fn fillet(pts: &[(Point, f64)], closed: bool) -> SubPath {
let n = pts.len();
let get = |i: isize| -> Option<Point> {
if closed { pts.get(i.rem_euclid(n as isize) as usize).map(|p| p.0) } else { usize::try_from(i).ok().and_then(|i| pts.get(i)).map(|p| p.0) }
};
let mut corners: Vec<(Point, Point, Point, Point)> = Vec::with_capacity(n);
for (i, (p, radius)) in pts.iter().enumerate() {
let (prev, next) = (get(i as isize - 1), get(i as isize + 1));
let (Some(a), Some(b)) = (prev, next) else {
corners.push((*p, *p, *p, *p));
continue;
};
let (u1, l1) = unit(*p, a);
let (u2, l2) = unit(*p, b);
let cos = (u1.x * u2.x + u1.y * u2.y).clamp(-1.0, 1.0);
let phi = cos.acos();
if *radius <= 0.0 || phi < 1e-6 || (PI - phi) < 1e-6 {
corners.push((*p, *p, *p, *p));
continue;
}
let t = (radius / (phi / 2.0).tan()).min(l1 / 2.0).min(l2 / 2.0);
let r = t * (phi / 2.0).tan();
let k = 4.0 / 3.0 * ((PI - phi) / 4.0).tan() * r;
let s = pt(p.x + u1.x * t, p.y + u1.y * t);
let e = pt(p.x + u2.x * t, p.y + u2.y * t);
corners.push((s, pt(s.x - u1.x * k, s.y - u1.y * k), pt(e.x - u2.x * k, e.y - u2.y * k), e));
}
let Some(first) = corners.first().copied() else { return SubPath { start: pt(0.0, 0.0), segments: vec![], closed } };
let mut segments = Vec::with_capacity(2 * n);
let mut cur = first.0;
let start = cur;
for (i, (s, c1, c2, e)) in corners.iter().enumerate() {
if i > 0 {
segments.push([cur, *s, *s]);
}
if s != e {
segments.push([*c1, *c2, *e]);
}
cur = *e;
}
if closed && cur != start {
segments.push([cur, start, start]);
}
SubPath { start, segments, closed }
}
fn unit(from: Point, to: Point) -> (Point, f64) {
let (dx, dy) = (to.x - from.x, to.y - from.y);
let l = (dx * dx + dy * dy).sqrt();
if l > 0.0 { (pt(dx / l, dy / l), l) } else { (pt(0.0, 0.0), 0.0) }
}
+402
View File
@@ -0,0 +1,402 @@
//! The tagged object stream inside `doc.dat`: a self-describing tree of objects whose fields carry
//! a type code and a four-character tag. Parsing needs no schema; unknown fields are kept, so the
//! document mapping can name what it could not import.
use crate::Error;
use crate::container::Cursor;
/// A four-character tag in its readable order (`Desc`, `Chld`).
#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Tag(pub u32);
impl Tag {
pub const fn of(s: &[u8; 4]) -> Self {
Self(u32::from_be_bytes(*s))
}
}
impl std::fmt::Debug for Tag {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", String::from_utf8_lossy(&self.0.to_be_bytes()))
}
}
impl std::fmt::Display for Tag {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
std::fmt::Debug::fmt(self, f)
}
}
/// Index of an object in [`Stream::objects`].
pub type ObjId = usize;
#[derive(Debug, Clone, PartialEq)]
pub enum Value {
Int(i64),
UInt(u64),
Float(f64),
Bool(bool),
Enum {
id: u16,
version: u16,
},
Str(String),
/// Fixed-length vectors of i32/f32/f64 (points, rectangles, transforms).
Ints(Vec<i64>),
Floats(Vec<f64>),
/// Fixed-size record (curve nodes, ids) or fixed-size struct (colour components).
Bytes(Vec<u8>),
Blob(Vec<u8>),
/// Name of another archive entry (raster tiles, embedded files).
Entry(String),
Flags(u64),
/// `None` is a null object.
Obj(Option<ObjId>),
Array(Vec<Value>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
Root,
/// Defined once and referenced by id elsewhere.
Shared,
Inline,
/// Untagged fields identified by position (curve data).
Positional,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Object {
pub class: Tag,
pub kind: Kind,
/// Class chain, most derived first (`ShpN`, `VNod`, `Node`).
pub chain: Vec<Tag>,
/// Fields in stream order; positional fields have tag 0.
pub fields: Vec<(Tag, Value)>,
}
impl Object {
pub fn get(&self, tag: Tag) -> Option<&Value> {
self.fields.iter().find(|(t, _)| *t == tag).map(|(_, v)| v)
}
}
#[derive(Debug)]
pub struct Stream {
pub root: ObjId,
pub objects: Vec<Object>,
}
/// Deepest object nesting accepted; real documents stay well below (each layer level adds three).
pub const MAX_DEPTH: usize = 384;
const MAX_OBJECTS: usize = 8_000_000;
/// Most values (fields and array elements) one stream may decode. A value takes about 40 bytes
/// in memory however few it took in the file (a bool array packs eight in a byte), so the size
/// of the input alone doesn't bound what parsing allocates: a small compressed entry could
/// otherwise ask for tens of gigabytes. Real documents stay far below.
pub const MAX_VALUES: usize = 1 << 24;
struct Parser<'a> {
c: Cursor<'a>,
objects: Vec<Object>,
/// Shared ids, defined at first use and referenced backwards.
shared: std::collections::HashMap<u32, ObjId>,
depth: usize,
/// Values decoded so far, against [`MAX_VALUES`].
values: usize,
}
/// Parse a stream (`00 FF 4B 53` header). Bytes after the root's terminator are ignored.
pub fn parse(bytes: &[u8]) -> Result<Stream, Error> {
let mut c = Cursor::new(bytes, 0);
if c.take(4)? != b"\x00\xffKS" {
return Err(Error::Malformed("document stream signature"));
}
let version = c.u16()?;
if version > 2 {
return Err(Error::Unsupported("document stream version"));
}
let class = Tag(c.u32()?);
c.u16()?;
if version == 2 {
c.u32()?;
}
let mut p = Parser { c, objects: Vec::new(), shared: std::collections::HashMap::new(), depth: 0, values: 0 };
let root = p.push(Object { class, kind: Kind::Root, chain: vec![class], fields: Vec::new() })?;
let fields = p.fields(true, class)?;
if let Some(o) = p.objects.get_mut(root) {
o.fields = fields;
}
Ok(Stream { root, objects: p.objects })
}
impl Parser<'_> {
fn push(&mut self, o: Object) -> Result<ObjId, Error> {
if self.objects.len() >= MAX_OBJECTS {
return Err(Error::Limit("too many objects"));
}
self.objects.push(o);
Ok(self.objects.len() - 1)
}
/// Counts `n` more values against [`MAX_VALUES`], before anything is allocated for them.
fn spend(&mut self, n: usize) -> Result<(), Error> {
self.values = self.values.checked_add(n).filter(|&v| v <= MAX_VALUES).ok_or(Error::Limit("too many values in the document stream"))?;
Ok(())
}
/// An empty array with room for `n` values, paid for first; an allocation that still fails
/// is an error, not an abort.
fn vec<T>(&mut self, n: usize) -> Result<Vec<T>, Error> {
self.spend(n)?;
let mut v = Vec::new();
v.try_reserve_exact(n).map_err(|_| Error::Limit("not enough memory for the document stream"))?;
Ok(v)
}
/// Element count of an array: each element takes at least `min` bytes, so a count the
/// remaining input cannot hold is rejected before anything is allocated.
fn count(&mut self, min: usize) -> Result<usize, Error> {
let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("array length"))?;
if n.checked_mul(min.max(1)).is_none_or(|bytes| bytes > self.c.remaining()) {
return Err(Error::Malformed("array longer than its data"));
}
Ok(n)
}
fn fields(&mut self, tagged: bool, class: Tag) -> Result<Vec<(Tag, Value)>, Error> {
self.depth += 1;
if self.depth > MAX_DEPTH {
return Err(Error::Limit("objects nested too deeply"));
}
let mut out = Vec::new();
loop {
let t = self.c.u8()?;
let code = t & 0x7F;
if code == 0 {
break;
}
self.spend(1)?;
let tag = if tagged { Tag(self.c.u32()?) } else { Tag(0) };
let v = if t & 0x80 != 0 { self.array(code, class)? } else { self.scalar(code, tagged, class)? };
out.push((tag, v));
}
self.depth -= 1;
Ok(out)
}
fn string(&mut self) -> Result<String, Error> {
let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("string length"))?;
Ok(String::from_utf8_lossy(self.c.take(n)?).into_owned())
}
fn floats(&mut self, n: usize, wide: bool) -> Result<Value, Error> {
let mut v = Vec::with_capacity(n);
for _ in 0..n {
v.push(if wide { f64::from_le_bytes(self.c.array()?) } else { f64::from(f32::from_le_bytes(self.c.array()?)) });
}
Ok(Value::Floats(v))
}
fn scalar(&mut self, code: u8, tagged: bool, class: Tag) -> Result<Value, Error> {
let c = &mut self.c;
Ok(match code {
0x01 => Value::UInt(c.u8()?.into()),
0x02 => Value::UInt(c.u16()?.into()),
0x03 | 0x2f | 0x34 => Value::UInt(c.u32()?.into()),
0x04 => Value::UInt(c.u64()?),
0x05 => Value::Int(i8::from_le_bytes(c.array()?).into()),
0x06 => Value::Int(i16::from_le_bytes(c.array()?).into()),
0x07 => Value::Int(i32::from_le_bytes(c.array()?).into()),
0x08 => Value::Int(i64::from_le_bytes(c.array()?)),
0x09 => Value::Float(f32::from_le_bytes(c.array()?).into()),
0x0a => Value::Float(f64::from_le_bytes(c.array()?)),
0x15..=0x19 => {
let n = usize::from(code - 0x13);
let mut v = Vec::with_capacity(n);
for _ in 0..n {
v.push(i32::from_le_bytes(c.array()?).into());
}
Value::Ints(v)
}
0x1f..=0x23 => return self.floats(usize::from(code - 0x1d), false),
0x24..=0x28 => return self.floats(usize::from(code - 0x22), true),
0x29 => Value::Bool(c.u8()? != 0),
0x2a => Value::Enum { id: c.u16()?, version: c.u16()? },
0x2b | 0x2e => Value::Str(self.string()?),
0x2c => {
let n = usize::from(c.u16()?);
Value::Bytes(c.take(n)?.to_vec())
}
0x35..=0x74 => Value::Bytes(c.take(usize::from(code - 0x34))?.to_vec()),
0x2d => {
let n = usize::try_from(c.u32()?).map_err(|_| Error::Malformed("blob length"))?;
Value::Blob(c.take(n)?.to_vec())
}
0x33 => {
c.u32()?;
let n = usize::try_from(c.u32()?).map_err(|_| Error::Malformed("entry name length"))?;
Value::Entry(String::from_utf8_lossy(c.take(n)?).into_owned())
}
0x75 => {
c.u16()?;
let n = usize::from(c.u8()?);
if n > 8 {
return Err(Error::Malformed("flags wider than 64 bits"));
}
let mut b = [0u8; 8];
b.get_mut(..n).ok_or(Error::Malformed("flags"))?.copy_from_slice(c.take(n)?);
Value::Flags(u64::from_le_bytes(b))
}
0x30 => {
let fields = self.fields(false, class)?;
let id = self.push(Object { class, kind: Kind::Positional, chain: Vec::new(), fields })?;
Value::Obj(Some(id))
}
0x31 | 0x32 if !tagged => return Err(Error::Malformed("object without a field tag")),
0x31 => self.shared()?,
0x32 => self.inline(None)?,
_ => return Err(Error::Unsupported("unknown field type in document stream")),
})
}
fn array(&mut self, code: u8, class: Tag) -> Result<Value, Error> {
let min = match code {
0x01 | 0x05 | 0x29 => 1,
0x02 | 0x06 => 2,
0x03 | 0x07 | 0x09 | 0x2f | 0x34 => 4,
0x04 | 0x08 | 0x0a => 8,
0x15..=0x19 => 4 * usize::from(code - 0x13),
0x1f..=0x23 => 4 * usize::from(code - 0x1d),
0x24..=0x28 => 8 * usize::from(code - 0x22),
0x35..=0x74 => usize::from(code - 0x34),
_ => 1,
};
match code {
0x29 => {
let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("array length"))?;
let bits = self.c.take(n.div_ceil(8))?;
let mut v = self.vec(n)?;
v.extend((0..n).map(|i| Value::Bool(bits.get(i / 8).is_some_and(|b| b >> (i % 8) & 1 != 0))));
Ok(Value::Array(v))
}
0x2a => {
let n = self.count(2)?;
let version = self.c.u16()?;
let mut v = self.vec(n)?;
for _ in 0..n {
v.push(Value::Enum { id: self.c.u16()?, version });
}
Ok(Value::Array(v))
}
0x2b | 0x2e => {
self.c.u32()?;
let n = self.count(4)?;
let mut v = self.vec(n)?;
for _ in 0..n {
v.push(Value::Str(self.string()?));
}
Ok(Value::Array(v))
}
0x2c => {
let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("array length"))?;
let size = usize::from(self.c.u16()?);
if n.checked_mul(size).is_none_or(|b| b > self.c.remaining()) {
return Err(Error::Malformed("array longer than its data"));
}
let mut v = self.vec(n)?;
for _ in 0..n {
v.push(Value::Bytes(self.c.take(size)?.to_vec()));
}
Ok(Value::Array(v))
}
0x32 => {
let n = self.count(1)?;
let class = Tag(self.c.u32()?);
self.c.u16()?;
let mut v = self.vec(n)?;
for _ in 0..n {
v.push(self.inline(Some(class))?);
}
Ok(Value::Array(v))
}
0x2d | 0x33 | 0x75 => Err(Error::Malformed("array of an unarrayable type")),
_ => {
let n = self.count(min)?;
let mut v = self.vec(n)?;
for _ in 0..n {
v.push(self.scalar(code, true, class)?);
}
Ok(Value::Array(v))
}
}
}
/// Inline object; `class` is given once for a whole array of them.
fn inline(&mut self, class: Option<Tag>) -> Result<Value, Error> {
match self.c.u8()? {
0 => return Ok(Value::Obj(None)),
1 => {}
_ => return Err(Error::Malformed("object marker")),
}
let class = match class {
Some(c) => c,
None => {
let c = Tag(self.c.u32()?);
self.c.u16()?;
c
}
};
let fields = self.fields(true, class)?;
let id = self.push(Object { class, kind: Kind::Inline, chain: vec![class], fields })?;
Ok(Value::Obj(Some(id)))
}
fn shared(&mut self) -> Result<Value, Error> {
let flag = self.c.u8()?;
if flag == 0 {
return Ok(Value::Obj(None));
}
let key = self.c.u32()?;
match flag {
2 => return self.shared.get(&key).map(|id| Value::Obj(Some(*id))).ok_or(Error::Malformed("reference to an undefined object")),
1 => {}
_ => return Err(Error::Malformed("object marker")),
}
if self.shared.contains_key(&key) {
return Err(Error::Malformed("object defined twice"));
}
// Register before the fields: an object's own fields may refer back to it.
let id = self.push(Object { class: Tag(0), kind: Kind::Shared, chain: Vec::new(), fields: Vec::new() })?;
self.shared.insert(key, id);
let mut chain = Vec::new();
let mut fields = Vec::new();
loop {
match self.c.u8()? {
0 => {
let tag = Tag(self.c.u32()?);
self.c.u16()?;
chain.push(tag);
fields.extend(self.fields(true, tag)?);
}
1 => {
chain.push(Tag(self.c.u32()?));
break;
}
2 => break,
_ => return Err(Error::Malformed("object class chain")),
}
if chain.len() > 64 {
return Err(Error::Limit("object class chain too long"));
}
}
let class = chain.first().copied().unwrap_or(Tag(0));
fields.extend(self.fields(true, class)?);
if let Some(o) = self.objects.get_mut(id) {
*o = Object { class, kind: Kind::Shared, chain, fields };
}
Ok(Value::Obj(Some(id)))
}
}
+259
View File
@@ -0,0 +1,259 @@
//! Synthetic containers for tests and fuzz seeds (cargo feature `synth`).
//!
//! These builders write the layout the reader accepts, so tests can describe documents in a few
//! lines. They are **not** an Affinity exporter: nothing written here has been opened in Affinity.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use crate::stream::Tag;
/// A field value to encode.
#[derive(Debug, Clone)]
pub enum F {
U8(u8),
U32(u32),
I32(i32),
F32(f32),
F64(f64),
Bool(bool),
Enum(u16, u16),
Str(String),
/// f64 vector of 2..6 values (points, rectangles, transforms).
F64s(Vec<f64>),
/// Fixed-size struct (colour components), 1..64 bytes.
Struct(Vec<u8>),
/// Array of fixed-size records, all `size` bytes.
Records(u16, Vec<Vec<u8>>),
U8s(Vec<u8>),
Entry(String),
/// Shared object: `Def` defines id with class chain and fields, `Ref` links back.
Def(u32, Vec<Tag>, Vec<(Tag, F)>),
Ref(u32),
Null,
/// Inline object.
Obj(Tag, Vec<(Tag, F)>),
/// Positional (untagged) object.
Pos(Vec<F>),
/// Array of shared objects / links.
Shared(Vec<F>),
/// Array of inline objects of one class.
Objs(Tag, Vec<Vec<(Tag, F)>>),
/// Raw bytes appended verbatim (malformed-input tests).
Raw(Vec<u8>),
}
pub fn tag(s: &[u8; 4]) -> Tag {
Tag::of(s)
}
fn put_tag(out: &mut Vec<u8>, t: Tag) {
out.extend(t.0.to_le_bytes());
}
fn code(f: &F) -> u8 {
match f {
F::U8(_) => 0x01,
F::U32(_) => 0x03,
F::I32(_) => 0x07,
F::F32(_) => 0x09,
F::F64(_) => 0x0a,
F::Bool(_) => 0x29,
F::Enum(..) => 0x2a,
F::Str(_) => 0x2b,
F::F64s(v) => 0x22 + v.len() as u8,
F::Struct(b) => 0x34 + b.len() as u8,
F::Records(..) => 0x80 | 0x2c,
F::U8s(_) => 0x80 | 0x01,
F::Entry(_) => 0x33,
F::Def(..) | F::Ref(_) => 0x31,
F::Null => 0x31,
F::Obj(..) => 0x32,
F::Pos(_) => 0x30,
F::Shared(_) => 0x80 | 0x31,
F::Objs(..) => 0x80 | 0x32,
F::Raw(_) => 0,
}
}
fn payload(out: &mut Vec<u8>, f: &F) {
match f {
F::U8(v) => out.push(*v),
F::U32(v) => out.extend(v.to_le_bytes()),
F::I32(v) => out.extend(v.to_le_bytes()),
F::F32(v) => out.extend(v.to_le_bytes()),
F::F64(v) => out.extend(v.to_le_bytes()),
F::Bool(v) => out.push(u8::from(*v)),
F::Enum(id, ver) => {
out.extend(id.to_le_bytes());
out.extend(ver.to_le_bytes());
}
F::Str(s) => {
out.extend((s.len() as u32).to_le_bytes());
out.extend(s.as_bytes());
}
F::F64s(v) => v.iter().for_each(|x| out.extend(x.to_le_bytes())),
F::Struct(b) => out.extend(b),
F::Records(size, recs) => {
out.extend((recs.len() as u32).to_le_bytes());
out.extend(size.to_le_bytes());
recs.iter().for_each(|r| out.extend(r));
}
F::U8s(v) => {
out.extend((v.len() as u32).to_le_bytes());
out.extend(v);
}
F::Entry(name) => {
put_tag(out, tag(b"Data"));
out.extend((name.len() as u32).to_le_bytes());
out.extend(name.as_bytes());
}
F::Def(id, chain, fields) => {
out.push(1);
out.extend(id.to_le_bytes());
// Class chain: each level but the last carries no fields here; the last ends the chain.
let (last, rest) = chain.split_last().expect("class chain");
for t in rest {
out.push(0);
put_tag(out, *t);
out.extend(0u16.to_le_bytes());
out.push(0);
}
out.push(1);
put_tag(out, *last);
fields_into(out, fields, true);
}
F::Ref(id) => {
out.push(2);
out.extend(id.to_le_bytes());
}
F::Null => out.push(0),
F::Obj(class, fields) => {
out.push(1);
put_tag(out, *class);
out.extend(0u16.to_le_bytes());
fields_into(out, fields, true);
}
F::Pos(items) => {
for f in items {
out.push(code(f));
payload(out, f);
}
out.push(0);
}
F::Shared(items) => {
out.extend((items.len() as u32).to_le_bytes());
items.iter().for_each(|f| payload(out, f));
}
F::Objs(class, items) => {
out.extend((items.len() as u32).to_le_bytes());
put_tag(out, *class);
out.extend(0u16.to_le_bytes());
for fields in items {
out.push(1);
fields_into(out, fields, true);
}
}
F::Raw(b) => out.extend(b),
}
}
fn fields_into(out: &mut Vec<u8>, fields: &[(Tag, F)], tagged: bool) {
for (t, f) in fields {
if let F::Raw(b) = f {
out.extend(b);
continue;
}
out.push(code(f));
if tagged {
put_tag(out, *t);
}
payload(out, f);
}
out.push(0);
}
/// A `doc.dat` stream with root class `Pers`.
pub fn stream(root: &[(Tag, F)]) -> Vec<u8> {
let mut out = b"\x00\xffKS".to_vec();
out.extend(2u16.to_le_bytes());
put_tag(&mut out, tag(b"Pers"));
out.extend(0u16.to_le_bytes());
out.extend(30u32.to_le_bytes());
fields_into(&mut out, root, true);
out
}
/// How an entry is stored.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Method {
Stored,
Zlib,
Zstd,
}
/// A version-12 container with one `#FT4` table holding `entries`, then an optional thumbnail record.
pub fn container(entries: &[(&str, &[u8], Method)], thumbnail_png: Option<&[u8]>) -> Vec<u8> {
let mut out = vec![0u8; 72];
out[..4].copy_from_slice(crate::container::MAGIC);
out[4..6].copy_from_slice(&12u16.to_le_bytes());
out[6..8].copy_from_slice(&4u16.to_le_bytes());
out[8..12].copy_from_slice(b"nsrP");
out[12..16].copy_from_slice(b"#Inf");
out[64..68].copy_from_slice(b"Prot");
let mut records = Vec::new();
for (i, (name, data, method)) in entries.iter().enumerate() {
let packed = match method {
Method::Stored => data.to_vec(),
Method::Zlib => {
use std::io::Write;
let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast());
e.write_all(data).unwrap();
e.finish().unwrap()
}
Method::Zstd => ruzstd::encoding::compress_to_vec(*data, ruzstd::encoding::CompressionLevel::Fastest),
};
let offset = out.len() as u64;
out.extend(b"#Fil");
out.extend(&packed);
let mut r = Vec::new();
r.extend((i as u32 + 1).to_le_bytes());
r.push(0);
r.extend(offset.to_le_bytes());
r.extend((data.len() as u64).to_le_bytes());
r.extend((packed.len() as u64).to_le_bytes());
r.extend(crc32fast::hash(data).to_le_bytes());
r.push(match method {
Method::Stored => 0,
Method::Zlib => 1,
Method::Zstd => 2,
});
r.extend(0u32.to_le_bytes());
r.extend(0u32.to_le_bytes());
r.extend((name.len() as u16).to_le_bytes());
r.extend(name.as_bytes());
records.push(r);
}
let fat = out.len() as u64;
out.extend(b"#FT4");
out.extend(0u64.to_le_bytes());
out.extend(1_700_000_000u64.to_le_bytes());
out.extend([0u8; 24]);
out.extend((records.len() as u32).to_le_bytes());
out.extend([0u8; 8]);
out.extend(0u16.to_le_bytes());
out.push(0);
records.iter().for_each(|r| out.extend(r));
let thumb = out.len() as u64;
out[16..24].copy_from_slice(&fat.to_le_bytes());
if let Some(png) = thumbnail_png {
out[24..32].copy_from_slice(&thumb.to_le_bytes());
out.extend(b"\xff\xff\xff\xffThmb");
out.extend(1u32.to_le_bytes());
out.extend((png.len() as u32 + 13).to_le_bytes());
out.extend(29u32.to_le_bytes());
out.extend(0u32.to_le_bytes());
out.extend((png.len() as u32).to_le_bytes());
out.push(1);
out.extend(png);
}
out
}
+354
View File
@@ -0,0 +1,354 @@
//! Unit tests on synthetic inputs; `tests/real_files.rs` covers public Affinity documents.
mod preview {
use crate::container::header;
use crate::*;
use proptest::prelude::*;
fn fixture() -> Vec<u8> {
let mut png = b"\x89PNG\r\n\x1a\n".to_vec();
png.extend(13u32.to_be_bytes());
png.extend(b"IHDR");
png.extend(2u32.to_be_bytes());
png.extend(1u32.to_be_bytes());
png.extend([8, 6, 0, 0, 0]);
png.extend(crc32fast::hash(&png[12..]).to_be_bytes());
png.extend([0; 4]);
png.extend(b"IDAT");
png.extend(crc32fast::hash(b"IDAT").to_be_bytes());
png.extend([0; 4]);
png.extend(b"IEND");
png.extend(crc32fast::hash(b"IEND").to_be_bytes());
let mut b = vec![0; 72];
b[..4].copy_from_slice(MAGIC);
b[4..6].copy_from_slice(&12u16.to_le_bytes());
b[8..12].copy_from_slice(b"nsrP");
b[12..16].copy_from_slice(b"#Inf");
b[24..32].copy_from_slice(&72u64.to_le_bytes());
b[64..68].copy_from_slice(b"Prot");
b.extend(b"\xff\xff\xff\xffThmb");
b.extend(1u32.to_le_bytes());
b.extend((png.len() as u32 + 13).to_le_bytes());
b.extend(29u32.to_le_bytes());
b.extend(0u32.to_le_bytes());
b.extend((png.len() as u32).to_le_bytes());
b.push(1);
b.extend(png);
b
}
#[test]
fn indexed_preview_and_header() {
let b = fixture();
assert_eq!(header(&b).unwrap().version, 12);
let p = preview(&b).unwrap();
assert_eq!((p.width, p.height), (2, 1));
assert_eq!(p.png, &b[101..]);
}
#[test]
fn every_truncation_is_rejected() {
let b = fixture();
for end in 0..b.len() {
assert!(preview(&b[..end]).is_err(), "{end}");
}
}
#[test]
fn rejects_assets_versions_offsets_lengths_and_bombs() {
let b = fixture();
for (offset, value) in [
(4, 13u64.to_le_bytes().to_vec()),
(8, b"urBR".to_vec()),
(24, u64::MAX.to_le_bytes().to_vec()),
(24, 1u64.to_le_bytes().to_vec()),
(24, 0u64.to_le_bytes().to_vec()),
(84, u32::MAX.to_le_bytes().to_vec()),
(88, 30u32.to_le_bytes().to_vec()),
(96, u32::MAX.to_le_bytes().to_vec()),
(117, 5000u32.to_be_bytes().to_vec()),
] {
let mut broken = b.clone();
broken[offset..offset + value.len()].copy_from_slice(&value);
assert!(preview(&broken).is_err(), "offset {offset}");
}
// Versions 8 to 11 store the same record (checked on public Affinity 1 and 2 files).
for version in 8..12 {
let mut legacy = b.clone();
legacy[4..6].copy_from_slice(&(version as u16).to_le_bytes());
assert!(preview(&legacy).is_ok());
}
// Flags 4 and 8 occur in real files; the low two bits mark other container variants.
for (flags, ok) in [(4u16, true), (8, true), (1, false), (2, false)] {
let mut variant = b.clone();
variant[6..8].copy_from_slice(&flags.to_le_bytes());
assert_eq!(preview(&variant).is_ok(), ok, "flags {flags}");
}
let mut old = b.clone();
old[4..6].copy_from_slice(&7u16.to_le_bytes());
assert!(matches!(preview(&old), Err(Error::Unsupported(_))));
}
#[test]
fn an_unindexed_png_or_resource_is_never_used() {
let mut b = fixture();
b[24..32].copy_from_slice(&0u64.to_le_bytes());
assert!(preview(&b).is_err());
b[24..32].copy_from_slice(&72u64.to_le_bytes());
b[76..80].copy_from_slice(b"Meta");
assert!(preview(&b).is_err());
}
#[test]
fn every_chunk_checksum_including_the_end_is_checked() {
let b = fixture();
for at in [130, b.len() - 1] {
let mut damaged = b.clone();
damaged[at] ^= 1;
assert_eq!(preview(&damaged).unwrap_err(), Error::Malformed("PNG chunk checksum"));
}
let mut ancillary = vec![0; 4];
ancillary.extend(b"tEXt");
ancillary.extend(crc32fast::hash(b"tEXt").to_be_bytes());
let mut with_ancillary = b.clone();
let at = b.len() - 12;
with_ancillary.splice(at..at, ancillary);
let png_length = (with_ancillary.len() - 101) as u32;
with_ancillary[84..88].copy_from_slice(&(png_length + 13).to_le_bytes());
with_ancillary[96..100].copy_from_slice(&png_length.to_le_bytes());
assert!(preview(&with_ancillary).is_ok());
with_ancillary[at + 8] ^= 1;
assert_eq!(preview(&with_ancillary).unwrap_err(), Error::Malformed("PNG chunk checksum"));
}
#[test]
fn compressed_metadata_and_animation_are_not_preview_support() {
for kind in [b"zTXt", b"iTXt", b"iCCP", b"acTL", b"fcTL", b"fdAT"] {
let mut extra = vec![0; 4];
extra.extend(kind);
extra.extend(crc32fast::hash(kind).to_be_bytes());
let mut b = fixture();
let at = b.len() - 12;
b.splice(at..at, extra);
let length = (b.len() - 101) as u32;
b[84..88].copy_from_slice(&(length + 13).to_le_bytes());
b[96..100].copy_from_slice(&length.to_le_bytes());
assert!(matches!(preview(&b), Err(Error::Unsupported(_))));
}
}
#[test]
fn critical_chunks_follow_a_single_complete_png_layout() {
fn with_chunks(kinds: &[&[u8; 4]]) -> Vec<u8> {
let original = fixture();
let mut b = original[..134].to_vec(); // File, record and complete first IHDR.
for kind in kinds {
b.extend([0; 4]);
b.extend(*kind);
b.extend(crc32fast::hash(*kind).to_be_bytes());
}
let length = (b.len() - 101) as u32;
b[84..88].copy_from_slice(&(length + 13).to_le_bytes());
b[96..100].copy_from_slice(&length.to_le_bytes());
b
}
assert!(preview(&with_chunks(&[b"IDAT", b"IDAT", b"IEND"])).is_ok());
// PNG readers must not reject an otherwise valid unknown ancillary chunk just
// because its reserved (third-letter) bit is set; future versions can define it.
assert!(preview(&with_chunks(&[b"abct", b"IDAT", b"IEND"])).is_ok());
for kinds in [
vec![b"IEND"],
vec![b"IDAT", b"IHDR", b"IEND"],
vec![b"PLTE", b"PLTE", b"IDAT", b"IEND"],
vec![b"IDAT", b"PLTE", b"IEND"],
vec![b"IDAT", b"tEXt", b"IDAT", b"IEND"],
vec![b"ABCD", b"IDAT", b"IEND"],
vec![b"IDAT", b"IEND", b"tEXt"],
] {
assert!(preview(&with_chunks(&kinds)).is_err(), "{kinds:?}");
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn arbitrary_input_never_panics(b in prop::collection::vec(any::<u8>(), 0..2048)) {
let _ = header(&b);
let _ = preview(&b);
}
#[test]
fn mutations_never_panic(edits in prop::collection::vec((0usize..200, any::<u8>()), 0..32)) {
let mut b = fixture();
for (i, value) in edits { if let Some(v) = b.get_mut(i) { *v = value; } }
let _ = preview(&b);
}
}
}
mod archive {
use crate::Error;
use crate::container::{Archive, Limits};
use crate::stream::{self, Kind, Tag, Value};
use crate::synth::{self, F, Method, tag};
use proptest::prelude::*;
fn doc() -> Vec<u8> {
synth::stream(&[
(tag(b"Desc"), F::Str("Document".into())),
(tag(b"Visi"), F::Bool(true)),
(tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, 5.0, 0.0, 1.0, -2.0])),
(tag(b"Colr"), F::Def(7, vec![tag(b"RGBA")], vec![(tag(b"_col"), F::Struct([0u8; 16].to_vec()))])),
(tag(b"Same"), F::Ref(7)),
(tag(b"Null"), F::Null),
(tag(b"Kids"), F::Shared(vec![F::Def(8, vec![tag(b"Grup"), tag(b"Node")], vec![(tag(b"Desc"), F::Str("g".into()))]), F::Ref(8)])),
(
tag(b"Crvs"),
F::Obj(tag(b"PCvD"), vec![(tag(b"Data"), F::Pos(vec![F::U8(0), F::U32(1), F::Bool(true), F::Records(18, vec![vec![0; 18]; 4])]))]),
),
(tag(b"Enum"), F::Enum(2, 1)),
(tag(b"Tile"), F::Entry("d/3".into())),
(tag(b"Stat"), F::U8s(vec![4, 0, 2])),
])
}
#[test]
fn every_method_round_trips_with_checksums() {
let d = doc();
for method in [Method::Stored, Method::Zlib, Method::Zstd] {
let file = synth::container(&[("doc.dat", &d, method), ("d/3", b"tile", method)], None);
let mut a = Archive::open(&file, Limits::default()).unwrap();
assert_eq!(a.header.version, 12);
assert_eq!(a.read("doc.dat").unwrap(), d);
assert_eq!(a.read("d/3").unwrap(), b"tile");
assert_eq!(a.extracted(), d.len() + 4);
assert!(a.read("missing").is_err());
}
}
#[test]
fn the_stream_parses_into_shared_objects_and_links() {
let s = stream::parse(&doc()).unwrap();
let root = &s.objects[s.root];
assert_eq!(root.class, Tag::of(b"Pers"));
assert_eq!(root.get(Tag::of(b"Desc")), Some(&Value::Str("Document".into())));
assert_eq!(root.get(Tag::of(b"Xfrm")), Some(&Value::Floats(vec![1.0, 0.0, 5.0, 0.0, 1.0, -2.0])));
let (Some(Value::Obj(Some(a))), Some(Value::Obj(Some(b)))) = (root.get(Tag::of(b"Colr")), root.get(Tag::of(b"Same"))) else { panic!() };
assert_eq!(a, b);
assert_eq!(s.objects[*a].class, Tag::of(b"RGBA"));
assert_eq!(s.objects[*a].kind, Kind::Shared);
let Some(Value::Array(kids)) = root.get(Tag::of(b"Kids")) else { panic!() };
assert_eq!(kids[0], kids[1]);
let Value::Obj(Some(g)) = kids[0] else { panic!() };
assert_eq!(s.objects[g].chain, vec![Tag::of(b"Grup"), Tag::of(b"Node")]);
assert_eq!(root.get(Tag::of(b"Enum")), Some(&Value::Enum { id: 2, version: 1 }));
assert_eq!(root.get(Tag::of(b"Tile")), Some(&Value::Entry("d/3".into())));
assert_eq!(root.get(Tag::of(b"Null")), Some(&Value::Obj(None)));
}
#[test]
fn checksums_sizes_and_budgets_are_enforced() {
let d = doc();
let file = synth::container(&[("doc.dat", &d, Method::Zstd)], None);
// Flip a byte inside the compressed payload, past the "#Fil" tag at offset 72.
let mut broken = file.clone();
broken[90] ^= 0x55;
let mut a = Archive::open(&broken, Limits::default()).unwrap();
assert!(a.read("doc.dat").is_err());
let mut a = Archive::open(&file, Limits { max_entry: d.len() - 1, max_total: usize::MAX }).unwrap();
assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_))));
let mut a = Archive::open(&file, Limits { max_entry: usize::MAX, max_total: d.len() + 1 }).unwrap();
a.read("doc.dat").unwrap();
assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_))));
}
#[test]
fn a_declared_size_smaller_than_the_data_is_rejected_not_truncated() {
let big = vec![7u8; 1 << 20];
let mut file = synth::container(&[("doc.dat", &big, Method::Zlib)], None);
// Rewrite the declared size in the table to 1 KiB: decoding must stop and fail.
let fat = u64::from_le_bytes(file[16..24].try_into().unwrap()) as usize;
let size_at = fat + 59 + 5 + 8;
file[size_at..size_at + 8].copy_from_slice(&1024u64.to_le_bytes());
let mut a = Archive::open(&file, Limits::default()).unwrap();
assert_eq!(a.read("doc.dat").unwrap_err(), Error::Malformed("archive entry size mismatch"));
}
#[test]
fn libraries_and_cyclic_tables_are_rejected() {
let d = doc();
let mut file = synth::container(&[("doc.dat", &d, Method::Stored)], None);
let mut library = file.clone();
library[8..12].copy_from_slice(b"rAsA");
assert!(matches!(Archive::open(&library, Limits::default()), Err(Error::Unsupported(_))));
let fat = u64::from_le_bytes(file[16..24].try_into().unwrap());
let at = fat as usize + 4;
file[at..at + 8].copy_from_slice(&fat.to_le_bytes());
assert_eq!(Archive::open(&file, Limits::default()).unwrap_err(), Error::Malformed("allocation tables form a cycle"));
}
#[test]
fn hostile_streams_fail_cleanly() {
let deep = (0..2000).fold(Vec::new(), |inner, _| vec![(tag(b"Chld"), F::Obj(tag(b"Grup"), inner))]);
assert_eq!(stream::parse(&synth::stream(&deep)).unwrap_err(), Error::Limit("objects nested too deeply"));
// An array claiming four billion elements in a few bytes.
let huge = synth::stream(&[(tag(b"Big_"), F::Raw(vec![0x81, b'_', b'g', b'i', b'B', 0xff, 0xff, 0xff, 0xff, 0]))]);
assert_eq!(stream::parse(&huge).unwrap_err(), Error::Malformed("array longer than its data"));
let forward = synth::stream(&[(tag(b"Link"), F::Ref(99))]);
assert!(stream::parse(&forward).is_err());
let twice = synth::stream(&[(tag(b"A___"), F::Def(1, vec![tag(b"RGBA")], vec![])), (tag(b"B___"), F::Def(1, vec![tag(b"RGBA")], vec![]))]);
assert!(stream::parse(&twice).is_err());
let unknown = synth::stream(&[(tag(b"X___"), F::Raw(vec![0x0b, 1, 2, 3, 4]))]);
assert!(matches!(stream::parse(&unknown), Err(Error::Unsupported(_))));
let d = doc();
for end in 0..d.len() {
assert!(stream::parse(&d[..end]).is_err(), "{end}");
}
}
#[test]
fn a_small_compressed_entry_cannot_make_the_stream_allocate_gigabytes() {
// Each decoded value takes about 40 bytes, and a bool array packs eight in a byte: 16 MiB
// of zero bits, a few KiB once compressed, would ask for 2^27 values (about 4 GiB). An
// array of one-byte values just past the budget fails the same way, before allocating.
let field = |code: u8, n: u32, data: usize| {
let mut f = vec![0x80 | code];
f.extend(tag(b"Big_").0.to_le_bytes());
f.extend(n.to_le_bytes());
f.resize(f.len() + data, 0);
F::Raw(f)
};
let bools = 1u32 << 27;
let bytes = u32::try_from(stream::MAX_VALUES).unwrap() + 1;
for f in [field(0x29, bools, (bools / 8) as usize), field(0x01, bytes, bytes as usize)] {
let d = synth::stream(&[(tag(b"Big_"), f)]);
let file = synth::container(&[("doc.dat", &d, Method::Zstd)], None);
assert!(file.len() < d.len() / 100, "{} of {}", file.len(), d.len());
let entry = Archive::open(&file, Limits::default()).unwrap().read("doc.dat").unwrap();
assert_eq!(stream::parse(&entry).unwrap_err(), Error::Limit("too many values in the document stream"));
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(512))]
#[test]
fn mutated_containers_and_streams_never_panic(edits in prop::collection::vec((0usize..4096, any::<u8>()), 1..24)) {
let d = doc();
for method in [Method::Stored, Method::Zlib, Method::Zstd] {
let mut file = synth::container(&[("doc.dat", &d, method)], None);
for (i, v) in &edits {
let n = file.len();
file[i % n] = *v;
}
if let Ok(bytes) = Archive::open(&file, Limits::default()).and_then(|mut a| a.read("doc.dat")) {
let _ = stream::parse(&bytes);
}
}
let mut s = d.clone();
for (i, v) in &edits {
let n = s.len();
s[i % n] = *v;
}
let _ = stream::parse(&s);
}
}
}
+151
View File
@@ -0,0 +1,151 @@
//! Artistic and frame text: the story's characters with their font, size, colour, tracking and
//! leading, the paragraph alignment and the first baseline. Layout (line breaks in frames, kerning,
//! OpenType features) is left to the importing application.
use crate::model::{Affine, Align, Point, Reader, Text, TextRun, rect};
use crate::paint::{self, Paint};
use crate::stream::{ObjId, Value};
/// Characters read per text node (stories are split into blocks; this caps hostile ones).
const MAX_CHARS: usize = 1 << 20;
pub(crate) fn read(r: &mut Reader, id: ObjId, world: Affine) -> Option<Text> {
let s = r.s;
let story = s.obj(id, b"StSt")?;
let frame = s.obj(id, b"TxtH")?;
let artistic = s.is(frame, b"ArFr");
let bounds = s.floats::<4>(frame, b"FrmB").map(rect)?;
let mut runs = Vec::new();
let mut align = None;
let mut total = 0usize;
let mut placeholders = false;
for block in s.objs(story, b"Blok") {
let glyphs = s.obj(block, b"Glyp")?;
let chars: Vec<char> = match s.str(glyphs, b"Utf8") {
Some(t) => t.chars().collect(),
None => {
// Inline objects (page numbers, anchors) each take one index before the segment text.
let mut v = Vec::new();
for seg in s.objs(glyphs, b"Mixd") {
let inline = s.objs(seg, b"Glys").len();
v.extend(std::iter::repeat_n('\u{FFFC}', inline));
placeholders |= inline > 0;
v.extend(s.str(seg, b"Utf8").unwrap_or_default().chars());
}
v
}
};
total = total.checked_add(chars.len())?;
if total > MAX_CHARS {
r.warn("text longer than a million characters (truncated)");
break;
}
let mut start = 0usize;
for run in s.obj(block, b"GAtt").map(|g| s.objs(g, b"Runs")).unwrap_or_default() {
let end = usize::try_from(s.int(run, b"Indx")?).ok()?.min(chars.len());
if end <= start {
continue;
}
let text: String = chars.get(start..end)?.iter().filter(|c| **c != '\0' && **c != '\u{FFFC}').collect();
start = end;
let Some(attrs) = s.obj(run, b"Item") else { continue };
if !text.is_empty() {
runs.push(run_attrs(r, attrs, text, world));
}
}
if align.is_none() {
align = s
.obj(block, b"PAtt")
.and_then(|p| s.objs(p, b"Runs").first().copied())
.and_then(|run| s.obj(run, b"Item"))
.map(|p| paragraph_align(r, p));
}
}
if placeholders {
r.warn("text fields such as page numbers (left out of the text)");
}
let align = align.unwrap_or(Align::Left);
let first_baseline = bounds.y0 + s.f64(frame, b"ArtV").unwrap_or(0.0);
let anchor_x = match align {
Align::Center => (bounds.x0 + bounds.x1) / 2.0,
Align::Right => bounds.x1,
_ => bounds.x0,
};
if !artistic {
if s.objs(frame, b"ColW").len() > 1 || matches!(s.field(frame, b"ColW"), Some(Value::Array(a)) if a.len() > 1) {
r.warn("text frames with several columns (imported as one column)");
}
if s.class(id) == Some(crate::stream::Tag::of(b"TxtC")) {
r.warn("text frames with a curved outline (imported as rectangular frames)");
}
}
Some(Text { runs, align, anchor: Point { x: anchor_x, y: first_baseline }, frame: if artistic { None } else { Some(bounds) }, transform: world })
}
fn slot<T: Copy>(v: Option<&Value>, i: usize, f: impl Fn(&Value) -> Option<T>) -> Option<T> {
match v? {
Value::Array(a) => a.get(i).and_then(f),
_ => None,
}
}
fn float(v: &Value) -> Option<f64> {
match v {
Value::Float(f) if f.is_finite() => Some(*f),
_ => None,
}
}
fn int(v: &Value) -> Option<i64> {
match v {
Value::Int(i) => Some(*i),
Value::UInt(u) => i64::try_from(*u).ok(),
_ => None,
}
}
fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine) -> TextRun {
let s = r.s;
let doubles = s.field(attrs, b"Doub");
let ints = s.field(attrs, b"Ints");
let size = slot(doubles, 0, float).filter(|v| *v > 0.0 && *v < 1e6).unwrap_or(12.0 * r.dpi / 72.0);
let tracking = slot(doubles, 4, float).unwrap_or(0.0);
let leading = (slot(ints, 4, int) == Some(1)).then(|| slot(doubles, 13, float)).flatten().filter(|v| *v > 0.0);
let objects = match s.field(attrs, b"Objs") {
Some(Value::Array(a)) => a.iter().map(|v| if let Value::Obj(o) = v { *o } else { None }).collect(),
_ => Vec::new(),
};
let fill = objects.first().copied().flatten().and_then(|f| paint::descriptor(r, f, world)).unwrap_or(Paint::None);
if objects.get(1).copied().flatten().and_then(|f| paint::descriptor(r, f, world)).is_some_and(|p| p != Paint::None) {
r.warn("outlined (stroked) text");
}
if slot(doubles, 10, float).is_some_and(|h| (h - 1.0).abs() > 1e-3) {
r.warn("horizontally scaled text");
}
let font = s.obj(attrs, b"DFnt");
let get = |tag: &[u8; 4]| font.and_then(|f| s.str(f, tag)).unwrap_or_default().to_string();
TextRun {
text,
postscript: get(b"Post"),
family: get(b"Famy"),
weight: font.and_then(|f| s.int(f, b"Wegt")).unwrap_or(400),
italic: font.and_then(|f| s.bool(f, b"Ital")).unwrap_or(false),
size,
tracking,
leading,
fill,
}
}
fn paragraph_align(r: &mut Reader, p: ObjId) -> Align {
match slot(r.s.field(p, b"Ints"), 0, int) {
Some(1) => Align::Center,
Some(2) => Align::Right,
Some(3) => Align::Justify,
Some(0) | None => Align::Left,
Some(_) => {
r.warn("an unknown paragraph alignment (imported as left)");
Align::Left
}
}
}
+83
View File
@@ -0,0 +1,83 @@
//! Public Affinity documents: the pinned set `cargo xtask corpus --affinity` fetches into
//! `corpus/affinity`, or any folder of `.af`/`.afdesign`/`.afphoto`/`.afpub` files named by
//! `AFFINITY_SAMPLES` (searched recursively). Without either, these tests do nothing.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::path::{Path, PathBuf};
use vectorcraft_affinity::{Archive, Limits, stream};
fn files(dir: &Path, out: &mut Vec<PathBuf>) {
for e in std::fs::read_dir(dir).into_iter().flatten().flatten() {
let p = e.path();
if p.is_dir() {
files(&p, out);
} else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| ["af", "afdesign", "afphoto", "afpub", "aftemplate"].contains(&e))
&& std::fs::read(&p).is_ok_and(|b| b.starts_with(vectorcraft_affinity::MAGIC))
{
out.push(p);
}
}
}
fn samples() -> Option<PathBuf> {
let pinned = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../corpus/affinity");
std::env::var_os("AFFINITY_SAMPLES").map(PathBuf::from).or_else(|| pinned.join("SOURCES.md").exists().then_some(pinned))
}
#[test]
fn public_documents_parse_completely() {
let Some(dir) = samples() else { return };
let mut paths = Vec::new();
files(&dir, &mut paths);
assert!(!paths.is_empty(), "no Affinity documents under {dir:?}");
let mut failures = Vec::new();
for p in &paths {
let bytes = std::fs::read(p).unwrap();
let result = Archive::open(&bytes, Limits::default()).and_then(|mut a| {
let doc = a.read("doc.dat")?;
stream::parse(&doc).map(|s| s.objects.len())
});
match result {
Ok(n) => assert!(n > 0),
Err(e) => failures.push(format!("{}: {e}", p.display())),
}
}
assert!(failures.is_empty(), "{} of {} failed:\n{}", failures.len(), paths.len(), failures.join("\n"));
eprintln!("{} Affinity documents parsed", paths.len());
}
#[test]
fn public_documents_map_to_the_model() {
let Some(dir) = samples() else { return };
let mut paths = Vec::new();
files(&dir, &mut paths);
let mut warnings = std::collections::BTreeMap::<String, usize>::new();
let mut failures = Vec::new();
let (mut nodes, mut docs) = (0usize, 0usize);
fn count(n: &[vectorcraft_affinity::model::Node]) -> usize {
n.iter().map(|n| 1 + count(&n.children)).sum()
}
for p in &paths {
let bytes = std::fs::read(p).unwrap();
if !bytes.starts_with(vectorcraft_affinity::MAGIC) {
continue;
}
match vectorcraft_affinity::read(&bytes, Limits::default()) {
Ok(d) => {
docs += 1;
nodes += d.spreads.iter().map(|s| count(&s.nodes)).sum::<usize>();
for w in d.warnings {
let key = w.split(" (").next().unwrap_or(&w).to_string();
*warnings.entry(key).or_default() += 1;
}
}
Err(e) => failures.push(format!("{}: {e}", p.display())),
}
}
eprintln!("{docs} documents, {nodes} nodes");
for (w, n) in &warnings {
eprintln!("{n:4} documents: {w}");
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
+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);
}
+86 -1
View File
@@ -1,7 +1,7 @@
//! ICC profiles through `moxcms` (pure Rust): built-in RGB spaces and user-supplied `.icc` files,
//! and the profiles exports embed ([`encode_builtin`]), written in code from the CMS.
use std::sync::{Arc, OnceLock};
use std::sync::{Arc, Mutex, OnceLock};
use moxcms::{
ColorProfile, DataColorSpace, Layout, LocalizableString, LutMultidimensionalType, LutStore, LutWarehouse, Matrix3d, ProfileClass, ProfileText,
@@ -27,6 +27,9 @@ fn intent_index(i: Intent) -> usize {
type Xf = Option<Arc<TransformF32Executor>>;
/// A direct transform's cache key: destination name, intent index, black-point compensation.
type DirectKey = (String, usize, bool);
/// A parsed ICC profile plus lazily-built transforms to/from sRGB.
pub struct IccProfile {
pub name: String,
@@ -37,6 +40,21 @@ pub struct IccProfile {
srgb: ColorProfile,
to_srgb: [OnceLock<Xf>; 4],
from_srgb: [OnceLock<Xf>; 4],
/// Direct transforms into other RGB profiles ([`Self::to_rgb_of`]), by destination, intent
/// and black-point compensation.
direct: Mutex<Vec<(DirectKey, Option<Arc<Direct>>)>>,
}
/// A transform from one profile into an RGB profile without passing through sRGB: into the
/// destination's primaries with linear tone curves, where black-point compensation is a linear
/// remap, then onto its own curves.
struct Direct {
to_linear: Arc<TransformF32Executor>,
/// Linear → the destination's curves; `None` when the destination isn't a matrix profile
/// (the first transform goes straight into it, without compensation).
to_dest: Option<Arc<TransformF32Executor>>,
/// The source black's luminance in the linear destination (0: no compensation).
black: f32,
}
impl std::fmt::Debug for IccProfile {
@@ -72,6 +90,7 @@ impl IccProfile {
srgb: ColorProfile::new_srgb(),
to_srgb: Default::default(),
from_srgb: Default::default(),
direct: Mutex::new(Vec::new()),
})
}
@@ -135,6 +154,72 @@ impl IccProfile {
Some(dst.into_iter().map(|x| x.clamp(0.0, 1.0)).collect())
}
/// Device values (RGB / CMYK / Gray, 0..1) → `dest`'s RGB, converted directly with `intent`:
/// not through sRGB, so colours outside sRGB keep their place in a wider space. With `bpc`
/// (and an intent other than absolute), this profile's black maps onto the destination's
/// black (black-point compensation, the darkest ink the profile prints becoming RGB 0 rather
/// than a dark grey). `None` when `dest` isn't RGB or no transform can be built.
pub fn to_rgb_of(&self, dest: &IccProfile, v: &[f32], intent: Intent, bpc: bool) -> Option<[f32; 3]> {
let t = self.direct_xf(dest, intent, bpc && intent != Intent::AbsoluteColorimetric)?;
let mut src = v.to_vec();
src.resize(self.channels(), 0.0);
let mut lin = [0.0f32; 3];
t.to_linear.transform(&src, &mut lin).ok()?;
let Some(to_dest) = &t.to_dest else { return Some(lin.map(|x| x.clamp(0.0, 1.0))) };
if t.black > 0.0 {
lin = lin.map(|x| (x - t.black) / (1.0 - t.black));
}
let lin = lin.map(|x| x.clamp(0.0, 1.0));
let mut out = [0.0f32; 3];
to_dest.transform(&lin, &mut out).ok()?;
Some(out.map(|x| x.clamp(0.0, 1.0)))
}
fn direct_xf(&self, dest: &IccProfile, intent: Intent, bpc: bool) -> Option<Arc<Direct>> {
if dest.kind != ProfileKind::Rgb {
return None;
}
let key = (dest.name.clone(), intent_index(intent), bpc);
let mut cache = self.direct.lock().unwrap_or_else(|e| e.into_inner());
if let Some((_, t)) = cache.iter().find(|(k, _)| *k == key) {
return t.clone();
}
let t = self.build_direct(dest, intent, bpc).map(Arc::new);
cache.push((key, t.clone()));
t
}
fn build_direct(&self, dest: &IccProfile, intent: Intent, bpc: bool) -> Option<Direct> {
let d = &dest.profile;
let matrix = d.red_trc.is_some() && d.green_trc.is_some() && d.blue_trc.is_some();
if !matrix {
let xf = self.profile.create_transform_f32(self.layout(), d, Layout::Rgb, Self::opts(intent)).ok()?;
return Some(Direct { to_linear: xf, to_dest: None, black: 0.0 });
}
let mut linear = d.clone();
for trc in [&mut linear.red_trc, &mut linear.green_trc, &mut linear.blue_trc] {
*trc = Some(ToneReprCurve::Parametric(vec![1.0]));
}
// A coding-independent code point names the transfer curve outright (moxcms's sRGB has
// one) and would override the linear curves.
linear.cicp = None;
let to_linear = self.profile.create_transform_f32(self.layout(), &linear, Layout::Rgb, Self::opts(intent)).ok()?;
let to_dest = linear.create_transform_f32(Layout::Rgb, d, Layout::Rgb, Self::opts(Intent::RelativeColorimetric)).ok()?;
// The source black: the darkest colour the profile reproduces, found as lcms does for an
// output profile (black sent into the profile and read back), made neutral.
let black = if bpc && self.kind == ProfileKind::Cmyk {
let inks = self.from_srgb([0.0; 3], Intent::RelativeColorimetric)?;
let mut lin = [0.0f32; 3];
to_linear.transform(&inks, &mut lin).ok()?;
let y = |c: &moxcms::Xyzd| c.y as f32;
let lum = lin[0] * y(&d.red_colorant) + lin[1] * y(&d.green_colorant) + lin[2] * y(&d.blue_colorant);
lum.clamp(0.0, 0.5)
} else {
0.0
};
Some(Direct { to_linear, to_dest: Some(to_dest), black })
}
/// Whether transforms can be built for this profile (checked at registration).
pub fn usable(&self) -> bool {
self.to_xf(Intent::RelativeColorimetric).is_some() && self.inverse_xf(Intent::RelativeColorimetric).is_some()
+32 -1
View File
@@ -356,10 +356,37 @@ impl Cms {
}
}
/// CMYK in the working space → display sRGB (`absolute` simulates the paper colour).
/// CMYK in the working space → display sRGB (`absolute` simulates the paper colour). An ICC
/// CMYK profile converts as [`Self::cmyk_to_rgb`] does into sRGB (with the settings'
/// black-point compensation), so what shows is what an RGB export writes.
pub fn cmyk_to_srgb(&self, cmyk: [f32; 4], absolute: bool) -> [f32; 3] {
if let (false, CmykSpace::Icc(p)) = (absolute, &self.cmyk)
&& let Some(srgb) = builtin_rgb_cache(SRGB)
&& let Some(v) = p.to_rgb_of(&srgb, &cmyk.map(|v| v.clamp(0.0, 1.0)), Intent::RelativeColorimetric, self.settings.bpc)
{
return v;
}
Self::cmyk_to_srgb_in(&self.cmyk, cmyk, absolute)
}
/// CMYK in the working space → working RGB with `intent`. An ICC CMYK profile converts
/// directly into the RGB profile: not through display sRGB, which would clip colours a wider
/// RGB space holds (Wide Gamut RGB keeps CMYK cyan that sRGB can't show), and with the
/// settings' black-point compensation. The built-in CMYK spaces go through sRGB as before
/// (Generic CMYK compensates in its own model).
pub fn cmyk_to_rgb(&self, cmyk: [f32; 4], intent: Intent) -> [f32; 3] {
let cmyk = cmyk.map(|v| v.clamp(0.0, 1.0));
let src = match &self.cmyk {
CmykSpace::Icc(p) => Some(p.clone()),
CmykSpace::Generic(_) | CmykSpace::Device => None,
};
let dest = match &self.rgb {
RgbSpace::Icc(p) => Some(p.clone()),
RgbSpace::Srgb => builtin_rgb_cache(SRGB),
};
src.zip(dest)
.and_then(|(s, d)| s.to_rgb_of(&d, &cmyk, intent, self.settings.bpc))
.unwrap_or_else(|| self.srgb_to_rgb(self.cmyk_to_srgb(cmyk, false)))
}
/// Display sRGB → working CMYK with `intent`.
pub fn srgb_to_cmyk(&self, srgb: [f32; 3], intent: Intent) -> [f32; 4] {
Self::srgb_to_cmyk_in(&self.cmyk, srgb, intent, self.settings.bpc)
@@ -424,6 +451,10 @@ impl Cms {
let [c, m, y, k] = self.to_cmyk(c, intent);
Color::Cmyk { c, m, y, k }
}
(Color::Cmyk { c, m, y, k }, Model::Rgb) => {
let [r, g, b] = self.cmyk_to_rgb([*c, *m, *y, *k], intent);
Color::Rgb { r, g, b }
}
(_, Model::Rgb) => {
let [r, g, b] = self.srgb_to_rgb(self.display_rgb(c));
Color::Rgb { r, g, b }
+51
View File
@@ -60,3 +60,54 @@ fn a_loaded_profile_is_embedded_as_loaded() {
register_icc(&bytes, Some("Embedded As Loaded RGB".into())).unwrap();
assert_eq!(&*icc_bytes("Embedded As Loaded RGB").unwrap(), &bytes[..]);
}
/// Settings with an ICC CMYK profile (Generic CMYK's, loaded as a file is) and Wide Gamut RGB.
fn icc_cmyk(bpc: bool) -> Cms {
let name = register_icc(&icc_bytes(GENERIC_CMYK).unwrap(), Some("Test ICC CMYK".into())).unwrap().name;
Cms::new(&ColorSettings { rgb: WIDE_GAMUT_RGB.into(), cmyk: name, bpc, ..ColorSettings::default() }).unwrap()
}
#[test]
fn icc_cmyk_converts_into_a_wide_rgb_space_directly_without_clipping_to_srgb() {
let wide = icc_cmyk(true);
let cyan = [1.0, 0.0, 0.0, 0.0];
let direct = wide.cmyk_to_rgb(cyan, Intent::RelativeColorimetric);
// Through sRGB, cyan clips to sRGB's gamut first: in the wider space that reads as a red
// channel well above zero.
let hub = wide.srgb_to_rgb(wide.cmyk_to_srgb(cyan, false));
assert!(direct[0] + 0.15 < hub[0], "direct {direct:?}, through sRGB {hub:?}");
// Same colour either way where sRGB holds it: a mid grey and paper white.
for inks in [[0.0, 0.0, 0.0, 0.5], [0.0; 4]] {
let (d, h) = (wide.cmyk_to_rgb(inks, Intent::RelativeColorimetric), wide.srgb_to_rgb(wide.cmyk_to_srgb(inks, false)));
assert!(d.iter().zip(h).all(|(a, b)| (a - b).abs() < 0.02), "{inks:?}: direct {d:?}, through sRGB {h:?}");
}
// Cms::convert takes the direct route for CMYK.
let Color::Rgb { r, .. } = wide.convert(&Color::Cmyk { c: 1.0, m: 0.0, y: 0.0, k: 0.0 }, Model::Rgb, Intent::RelativeColorimetric) else {
panic!("RGB")
};
assert!((r - direct[0]).abs() < 1e-6);
}
#[test]
fn black_point_compensation_maps_the_darkest_ink_to_rgb_black() {
let rich = [0.75, 0.68, 0.67, 0.9];
let (on, off) = (icc_cmyk(true).cmyk_to_rgb(rich, Intent::RelativeColorimetric), icc_cmyk(false).cmyk_to_rgb(rich, Intent::RelativeColorimetric));
// Without compensation the profile's black is a dark grey; with it, rich black goes most of
// the way to RGB black (not all: it isn't quite the darkest ink the profile prints).
assert!(off.iter().all(|v| *v > 0.1), "without: {off:?}");
assert!(on.iter().zip(off).all(|(a, b)| *a < 0.08 && *a < b * 0.5), "with: {on:?}, without: {off:?}");
// The screen shows what an sRGB conversion writes, compensated alike.
let srgb = |bpc| {
let c = icc_cmyk(bpc);
Cms::new(&ColorSettings { rgb: SRGB.into(), ..c.settings().clone() }).unwrap()
};
for bpc in [true, false] {
let c = srgb(bpc);
assert_eq!(c.cmyk_to_srgb(rich, false), c.cmyk_to_rgb(rich, Intent::RelativeColorimetric), "bpc {bpc}");
}
// White is untouched, and absolute colorimetric never compensates.
let white = icc_cmyk(true).cmyk_to_rgb([0.0; 4], Intent::RelativeColorimetric);
assert!(white.iter().all(|v| *v > 0.99), "{white:?}");
let abs = |bpc| icc_cmyk(bpc).cmyk_to_rgb(rich, Intent::AbsoluteColorimetric);
assert_eq!(abs(true), abs(false));
}
+332 -8
View File
@@ -1,6 +1,9 @@
//! Swatch library files: the native `.vcswatches` JSON (colour models, global, spot, gradients
//! and colour groups exactly), `.gpl` palettes (8-bit RGB; colour groups as `# Group:` comment
//! headers) and CSS custom properties (written only).
//! headers) and CSS custom properties (written only). Swatch exchange files (`.ase`) are read
//! only ([`read_bytes`]).
use std::collections::{HashMap, HashSet};
use serde::{Deserialize, Serialize};
@@ -58,6 +61,15 @@ struct NativeFile {
const NATIVE_FORMAT: &str = "vcswatches";
const GPL_HEADER: &str = "GIMP Palette";
const GPL_GROUP: &str = "# Group:";
const ASE_SIGNATURE: &[u8] = b"ASEF";
const ASE_GROUP_START: u16 = 0xC001;
const ASE_GROUP_END: u16 = 0xC002;
const ASE_COLOR: u16 = 0x0001;
/// The most colors and groups read from one `.ase` file.
const ASE_MAX_ENTRIES: usize = 100_000;
/// The most UTF-16 code units of a name that are kept.
const ASE_MAX_NAME: usize = 1024;
const ASE_CUT_SHORT: &str = "the swatch exchange file is cut short";
/// Write `lib` in `format`. Pattern swatches (their tiles live in a document) and None are left
/// out; `.gpl` keeps solid colours only, as 8-bit RGB.
@@ -199,16 +211,34 @@ pub fn sniff(text: &str) -> bool {
t.starts_with(GPL_HEADER) || (t.starts_with('{') && t.contains(NATIVE_FORMAT))
}
/// Read the bytes of a library file: a swatch exchange `.ase` file, else a `.vcswatches` or
/// `.gpl` library ([`read`]). `name` names it when the file doesn't.
pub fn read_bytes(bytes: &[u8], name: &str) -> Result<SwatchLibrary, String> {
if bytes.starts_with(ASE_SIGNATURE) {
return read_ase(bytes, name);
}
read(&String::from_utf8_lossy(bytes), name)
}
/// Are `bytes` a library [`read_bytes`] understands? Text that isn't valid UTF-8 is checked as
/// [`read_bytes`] reads it, with the invalid bytes replaced (its first KiB is enough).
pub fn sniff_bytes(bytes: &[u8]) -> bool {
bytes.starts_with(ASE_SIGNATURE)
|| std::str::from_utf8(bytes).is_ok_and(sniff)
|| sniff(&String::from_utf8_lossy(bytes.get(..1024).unwrap_or(bytes)))
}
/// `.gpl`: `Name:` names the library, `# Group: name` starts a colour group, other `#` lines and
/// headers (`Columns:`) are skipped, as are lines that aren't `r g b [name]`. Unnamed colours are
/// named by their values; repeated names get a number.
/// headers (`Columns:`) are skipped, as are lines that aren't `r g b [name]`. Unnamed colors are
/// named by their values and unnamed groups "Color Group"; a name a color or group already has gets
/// a number ([`UniqueNames`]).
fn read_gpl(text: &str, fallback: &str) -> SwatchLibrary {
let mut lib = SwatchLibrary { name: fallback.into(), ..Default::default() };
let mut names: Vec<String> = vec![];
let mut names = UniqueNames::default();
for line in text.lines().skip(1) {
let line = line.trim();
if let Some(g) = line.strip_prefix(GPL_GROUP) {
lib.groups.push(SwatchGroup { name: g.trim().to_string(), swatches: vec![] });
lib.groups.push(SwatchGroup { name: names.unique(group_name(g)), swatches: vec![] });
continue;
}
if let Some(n) = line.strip_prefix("Name:") {
@@ -221,9 +251,7 @@ fn read_gpl(text: &str, fallback: &str) -> SwatchLibrary {
let [r, g, b] = rgb[..] else { continue };
let rest = words[3..].join(" ");
let base = if rest.is_empty() { format!("R={r} G={g} B={b}") } else { rest };
let name = (1..).map(|i| if i == 1 { base.clone() } else { format!("{base} {i}") }).find(|n| !names.contains(n)).unwrap_or(base);
names.push(name.clone());
let w = Swatch { name, paint: Paint::solid(Color::rgb8(r, g, b)), global: false, spot: false };
let w = Swatch { name: names.unique(base), paint: Paint::solid(Color::rgb8(r, g, b)), global: false, spot: false };
match lib.groups.last_mut() {
Some(grp) => grp.swatches.push(w),
None => lib.swatches.push(w),
@@ -232,6 +260,184 @@ fn read_gpl(text: &str, fallback: &str) -> SwatchLibrary {
lib
}
/// `.ase`, from public descriptions of the format: the layout from
/// <https://www.selapa.net/swatches/colors/fileformats.php>, the component ranges from
/// <https://github.com/nsfmc/swatch>. After the signature come, big-endian, a `u16` major (1) and
/// minor version, a `u32` block count and the blocks, each a `u16` type, a `u32` body length and
/// the body. A group start's body is a name. A color's body is a name, a four-character model
/// (`RGB `, `CMYK`, `LAB `, `Gray`), its components as `f32` (3, 4, 3 and 1 of them) and a `u16`
/// color type (0 global, 1 spot, 2 process). RGB, CMYK and gray components run from 0 to 1 (a
/// gray level of 1 is white), Lab lightness from 0 to 1 and a and b from −128 to 127. A lightness
/// above 1 is read as L* itself. A name is a `u16` count of UTF-16 code units, the terminating zero
/// included, then the units.
///
/// Color groups become groups: a group start inside an open group ends that group, and a group
/// left open ends with the file. Blocks of other types and colors in other models are skipped.
/// Unnamed colors are named by their values and unnamed groups "Color Group"; a name a color or
/// group already has gets a number ([`UniqueNames`]). A truncated file, a major version other than
/// 1 or more than [`ASE_MAX_ENTRIES`] colors and groups is an error.
fn read_ase(bytes: &[u8], fallback: &str) -> Result<SwatchLibrary, String> {
let mut data = AseData(bytes);
data.bytes(ASE_SIGNATURE.len())?;
let (major, minor) = (data.u16()?, data.u16()?);
if major != 1 {
return Err(format!("swatch exchange version {major}.{minor} isn't supported (only 1.x)"));
}
let blocks = data.u32()?;
let mut lib = SwatchLibrary { name: fallback.into(), ..Default::default() };
let mut names = UniqueNames::default();
let (mut in_group, mut entries) = (false, 0usize);
for _ in 0..blocks {
let kind = data.u16()?;
let len = usize::try_from(data.u32()?).unwrap_or(usize::MAX);
let mut body = AseData(data.bytes(len)?);
if matches!(kind, ASE_GROUP_START | ASE_COLOR) {
entries += 1;
if entries > ASE_MAX_ENTRIES {
return Err(format!("the swatch exchange file holds more than {ASE_MAX_ENTRIES} colors and groups"));
}
}
match kind {
ASE_GROUP_START => {
lib.groups.push(SwatchGroup { name: names.unique(group_name(&body.name()?)), swatches: vec![] });
in_group = true;
}
ASE_GROUP_END => in_group = false,
ASE_COLOR => {
let Some(mut w) = ase_swatch(&mut body)? else { continue };
w.name = names.unique(std::mem::take(&mut w.name));
match lib.groups.last_mut().filter(|_| in_group) {
Some(g) => g.swatches.push(w),
None => lib.swatches.push(w),
}
}
// Other blocks are skipped.
_ => {}
}
}
Ok(lib)
}
/// The swatch of an `.ase` color block, `None` for a model other than RGB, CMYK, Lab and Gray.
/// Components are clamped to their ranges.
fn ase_swatch(body: &mut AseData) -> Result<Option<Swatch>, String> {
let name = body.name()?;
let unit = |v: f32| v.clamp(0.0, 1.0);
let color = match &body.array::<4>()? {
b"RGB " => {
let [r, g, b] = [body.f32()?, body.f32()?, body.f32()?];
Color::rgb(unit(r), unit(g), unit(b))
}
b"CMYK" => {
let [c, m, y, k] = [body.f32()?, body.f32()?, body.f32()?, body.f32()?];
Color::cmyk(unit(c), unit(m), unit(y), unit(k))
}
// Lightness is stored as a fraction of 100. Some writers store L* itself, which shows as a
// value above 1.
b"LAB " => {
let [l, a, b] = [body.f32()?, body.f32()?, body.f32()?];
let l = if l > 1.0 { l.min(100.0) } else { unit(l) * 100.0 };
Color::lab(l, a.clamp(-128.0, 127.0), b.clamp(-128.0, 127.0))
}
// A gray level (1 is white); a gray color holds its ink.
b"Gray" => Color::gray(1.0 - unit(body.f32()?)),
_ => return Ok(None),
};
// Some writers leave the color type out, which makes a process color.
let kind = if body.0.len() >= 2 { body.u16()? } else { 2 };
// Spot colors are global.
let (global, spot) = match kind {
0 => (true, false),
1 => (true, true),
_ => (false, false),
};
let name = if name.is_empty() { value_name(color) } else { name };
Ok(Some(Swatch { name, paint: Paint::solid(color), global, spot }))
}
/// A color's values in its own model, as new swatches are named ("C=10 M=20 Y=30 K=0",
/// "R=255 G=128 B=0", "Gray K=40", "L=52 a=70 b=-30").
fn value_name(c: Color) -> String {
let pct = |v: f32| (v * 100.0).round();
let byte = |v: f32| (v * 255.0).round();
match c {
Color::Cmyk { c, m, y, k } => format!("C={} M={} Y={} K={}", pct(c), pct(m), pct(y), pct(k)),
Color::Rgb { r, g, b } => format!("R={} G={} B={}", byte(r), byte(g), byte(b)),
Color::Gray { k } => format!("Gray K={}", pct(k)),
// `+ 0.0` turns a rounded -0 into 0.
Color::Lab { l, a, b } => format!("L={} a={} b={}", l.round() + 0.0, a.round() + 0.0, b.round() + 0.0),
}
}
/// `.ase` data, read field by field; reading past its end is an error.
struct AseData<'a>(&'a [u8]);
impl<'a> AseData<'a> {
fn bytes(&mut self, n: usize) -> Result<&'a [u8], String> {
let (head, rest) = self.0.split_at_checked(n).ok_or(ASE_CUT_SHORT)?;
self.0 = rest;
Ok(head)
}
fn array<const N: usize>(&mut self) -> Result<[u8; N], String> {
let (head, rest) = self.0.split_first_chunk::<N>().ok_or(ASE_CUT_SHORT)?;
self.0 = rest;
Ok(*head)
}
fn u16(&mut self) -> Result<u16, String> {
self.array().map(u16::from_be_bytes)
}
fn u32(&mut self) -> Result<u32, String> {
self.array().map(u32::from_be_bytes)
}
/// A component; one that isn't a finite number reads as 0.
fn f32(&mut self) -> Result<f32, String> {
let v = f32::from_be_bytes(self.array()?);
Ok(if v.is_finite() { v } else { 0.0 })
}
/// A name up to its terminating zero, trimmed. A block that ends before it has no name.
fn name(&mut self) -> Result<String, String> {
if self.0.is_empty() {
return Ok(String::new());
}
let n = usize::from(self.u16()?);
let (units, _) = self.bytes(n * 2)?.as_chunks::<2>();
let units: Vec<u16> = units.iter().take(ASE_MAX_NAME).map(|u| u16::from_be_bytes(*u)).take_while(|&u| u != 0).collect();
Ok(String::from_utf16_lossy(&units).trim().to_string())
}
}
/// A group's name as a file gives it, trimmed; "Color Group" when it has none.
fn group_name(name: &str) -> String {
let name = name.trim();
if name.is_empty() { "Color Group".into() } else { name.to_string() }
}
/// The swatch and group names a reader has handed out. Swatches and color groups share one set of
/// names, as in a document, and a name already taken gets the first free number ("Black 2").
#[derive(Default)]
struct UniqueNames {
taken: HashSet<String>,
/// The last number tried for each repeated name.
last: HashMap<String, usize>,
}
impl UniqueNames {
fn unique(&mut self, base: String) -> String {
if self.taken.insert(base.clone()) {
return base;
}
// Each pass tries the next number, and only finitely many names are taken.
let n = self.last.entry(base.clone()).or_insert(1);
loop {
*n += 1;
let name = format!("{base} {n}");
if self.taken.insert(name.clone()) {
return name;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -294,6 +500,11 @@ mod tests {
assert_eq!(names, ["Sky Blue", "R=10 G=20 B=30", "Black", "Black 2"]);
assert_eq!(lib.swatches[0].paint.color(), Some(Color::rgb8(0, 128, 255)));
assert_eq!(read("GIMP Palette\n1 2 3 x\n", "Fallback").unwrap().name, "Fallback");
// Colors and groups share names; an unnamed group is "Color Group".
let lib = read("GIMP Palette\n0 0 0 Black\n# Group: Black\n1 1 1 Black\n# Group:\n", "x").unwrap();
let groups: Vec<(&str, Vec<&str>)> =
lib.groups.iter().map(|g| (g.name.as_str(), g.swatches.iter().map(|w| w.name.as_str()).collect())).collect();
assert_eq!(groups, [("Black 2", vec!["Black 3"]), ("Color Group", vec![])]);
}
#[test]
@@ -321,4 +532,117 @@ mod tests {
assert!(text.contains("--red: #000000;") && text.contains("--red-2: #000000;"));
assert!(read(&text, "x").is_err(), "CSS is written only");
}
/// A swatch exchange file of `blocks` (type, body).
fn ase(blocks: &[(u16, Vec<u8>)]) -> Vec<u8> {
let mut out = b"ASEF\0\x01\0\0".to_vec();
out.extend((blocks.len() as u32).to_be_bytes());
for (kind, body) in blocks {
out.extend(kind.to_be_bytes());
out.extend((body.len() as u32).to_be_bytes());
out.extend(body);
}
out
}
/// A name as `.ase` stores it.
fn ase_name(name: &str) -> Vec<u8> {
let units: Vec<u16> = name.encode_utf16().chain([0]).collect();
let mut out = (units.len() as u16).to_be_bytes().to_vec();
out.extend(units.iter().flat_map(|u| u.to_be_bytes()));
out
}
/// A color block: name, model, components and color type.
fn ase_color(name: &str, model: &[u8; 4], values: &[f32], kind: u16) -> (u16, Vec<u8>) {
let mut body = ase_name(name);
body.extend(model);
body.extend(values.iter().flat_map(|v| v.to_be_bytes()));
body.extend(kind.to_be_bytes());
(ASE_COLOR, body)
}
#[test]
fn ase_reads_color_models_kinds_and_groups() {
let bytes = ase(&[
ase_color("Sky", b"RGB ", &[0.0, 0.5, 1.0], 0),
ase_color("Ink", b"CMYK", &[1.0, 0.5, 0.0, 0.2], 1),
(ASE_GROUP_START, ase_name("Neutrals")),
ase_color("Mist", b"Gray", &[0.75], 2),
ase_color("Clay", b"LAB ", &[0.5, 20.0, -30.0], 2),
(ASE_GROUP_END, vec![]),
ase_color("Paper", b"RGB ", &[1.0, 1.0, 1.0], 2),
]);
assert!(sniff_bytes(&bytes));
let sw = |name: &str, color, global, spot| Swatch { name: name.into(), paint: Paint::solid(color), global, spot };
let expected = SwatchLibrary {
name: "Brand".into(),
swatches: vec![
sw("Sky", Color::rgb(0.0, 0.5, 1.0), true, false),
sw("Ink", Color::cmyk(1.0, 0.5, 0.0, 0.2), true, true),
sw("Paper", Color::WHITE, false, false),
],
groups: vec![SwatchGroup {
name: "Neutrals".into(),
swatches: vec![sw("Mist", Color::gray(0.25), false, false), sw("Clay", Color::lab(50.0, 20.0, -30.0), false, false)],
}],
};
assert_eq!(read_bytes(&bytes, "Brand").unwrap(), expected, "named after the file; 0 global, 1 spot, 2 process");
// A lightness above 1 is L* itself.
let deep = read_bytes(&ase(&[ase_color("Deep", b"LAB ", &[53.0, 20.0, -30.0], 2)]), "x").unwrap();
assert_eq!(deep.swatches[0].paint.color(), Some(Color::lab(53.0, 20.0, -30.0)));
// Text libraries read through the same call, also when they aren't valid UTF-8.
assert!(sniff_bytes(b"GIMP Palette\n1 2 3 x\n") && !sniff_bytes(b"ASE") && !sniff_bytes(&[0xff, 0xfe, 0]));
assert_eq!(read_bytes(b"GIMP Palette\n1 2 3 x\n", "F").unwrap().swatches.len(), 1);
let latin1 = b"GIMP Palette\n1 2 3 Caf\xe9\n";
assert!(sniff_bytes(latin1));
assert_eq!(read_bytes(latin1, "F").unwrap().swatches.len(), 1);
}
#[test]
fn ase_names_unnamed_and_repeated_entries_and_skips_other_blocks() {
let mut untyped = ase_color("Plain", b"RGB ", &[0.2, 0.4, 0.6], 2);
untyped.1.truncate(untyped.1.len() - 2);
let bytes = ase(&[
ase_color("Café 🎨", b"RGB ", &[1.0, 0.0, 0.0], 2),
ase_color("", b"CMYK", &[0.1, 0.2, 0.3, 0.0], 2),
ase_color(" Red ", b"RGB ", &[2.0, -1.0, f32::NAN], 0),
ase_color("Red", b"Gray", &[0.0], 2),
untyped,
ase_color("Hue", b"HSB ", &[0.1, 0.2, 0.3], 2),
(0x0042, vec![1, 2, 3]),
(ASE_GROUP_END, vec![]),
(ASE_GROUP_START, ase_name("")),
(ASE_GROUP_START, ase_name("Red")),
ase_color("Red", b"Gray", &[1.0], 2),
]);
let lib = read_bytes(&bytes, "x").unwrap();
let names: Vec<&str> = lib.swatches.iter().map(|w| w.name.as_str()).collect();
assert_eq!(names, ["Café 🎨", "C=10 M=20 Y=30 K=0", "Red", "Red 2", "Plain"], "an HSB color and an unknown block are skipped");
assert_eq!(lib.swatches[2].paint.color(), Some(Color::rgb(1.0, 0.0, 0.0)), "clamped, NaN read as 0");
assert!(lib.swatches[2].global && !lib.swatches[4].global, "without a color type: process");
// A group start ends the open group, and groups and colors share names.
let groups: Vec<(&str, Vec<&str>)> =
lib.groups.iter().map(|g| (g.name.as_str(), g.swatches.iter().map(|w| w.name.as_str()).collect())).collect();
assert_eq!(groups, [("Color Group", vec![]), ("Red 3", vec!["Red 4"])]);
assert_eq!(lib.groups[1].swatches[0].paint.color(), Some(Color::gray(0.0)), "gray level 1 is white");
}
#[test]
fn damaged_ase_files_are_errors() {
let good = ase(&[(ASE_GROUP_START, ase_name("G")), ase_color("Sky", b"RGB ", &[0.0, 0.5, 1.0], 0)]);
assert_eq!(read_bytes(&good, "x").unwrap().len(), 1);
for n in ASE_SIGNATURE.len()..good.len() {
assert!(read_bytes(&good[..n], "x").is_err(), "cut at {n}");
}
let mut v2 = good.clone();
v2[5] = 2;
assert!(read_bytes(&v2, "x").unwrap_err().contains("2.0"), "version 2.0");
// Too many colors, or too many groups (empty group starts take 6 bytes each).
let colors: Vec<(u16, Vec<u8>)> = (0..=ASE_MAX_ENTRIES).map(|_| ase_color("Gray", b"Gray", &[0.5], 2)).collect();
assert!(read_bytes(&ase(&colors), "x").unwrap_err().contains("more than"));
let groups: Vec<(u16, Vec<u8>)> = (0..=ASE_MAX_ENTRIES).map(|_| (ASE_GROUP_START, vec![])).collect();
assert!(read_bytes(&ase(&groups), "x").unwrap_err().contains("more than"));
assert_eq!(read_bytes(&ase(&groups[1..]), "x").unwrap().groups.len(), ASE_MAX_ENTRIES, "up to the limit");
}
}
+55
View File
@@ -136,10 +136,65 @@ fn tiff_inks(bytes: &[u8]) -> Option<Inks> {
Inks::new(w, h, data)
}
/// A CMYK TIFF with an alpha channel (5 samples) as RGBA pixels, each colour converted by `rgb`
/// (ink amounts 0..1 → sRGB 0..1) and associated (premultiplied) alpha divided back out; `None`
/// for other images, planar ones and ones larger than [`MAX_CMYK_PIXELS`]. The image decoder
/// doesn't read these at all; the inks can't be kept with the transparency, so they show as RGB.
pub fn cmyka_tiff_rgba(bytes: &[u8], rgb: impl Fn([f32; 4]) -> [f32; 3]) -> Option<image::RgbaImage> {
let (kind, mut d) = tiff(bytes)?;
let (w, h) = d.dimensions().ok()?;
let planar = d.find_tag_unsigned::<u16>(tiff::tags::Tag::PlanarConfiguration).ok().flatten().is_some_and(|p| p != 1);
if !matches!(kind, tiff::ColorType::CMYKA(8 | 16)) || planar {
return None;
}
let n = usize::try_from(pixels(w, h)?).ok()?;
// ExtraSamples 1: associated alpha (premultiplied colour); 2 (or none stated): unassociated.
let associated = d.find_tag_unsigned_vec::<u16>(tiff::tags::Tag::ExtraSamples).ok().flatten().is_some_and(|v| v.first() == Some(&1));
let data: Vec<u8> = match d.read_image().ok()? {
tiff::decoder::DecodingResult::U8(v) => v,
tiff::decoder::DecodingResult::U16(v) => v.iter().map(|s| (s >> 8) as u8).collect(),
_ => return None,
};
if data.len() != n * 5 {
return None;
}
let mut out = Vec::with_capacity(n * 4);
for p in data.as_chunks::<5>().0 {
let a = f32::from(p[4]) / 255.0;
let ink = |v: u8| {
let v = f32::from(v) / 255.0;
if associated && a > 0.0 { (v / a).min(1.0) } else { v }
};
let [r, g, b] = rgb([ink(p[0]), ink(p[1]), ink(p[2]), ink(p[3])]);
out.extend([r, g, b].map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8));
out.push(p[4]);
}
image::RgbaImage::from_raw(w, h, out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_cmyk_tiff_with_alpha_reads_as_rgba() {
use tiff::encoder::{TiffEncoder, colortype::CMYKA8};
// 2 × 1: full cyan opaque, then 100% K at half alpha (premultiplied: K 128, alpha 128).
let mut file = Cursor::new(Vec::new());
let mut enc = TiffEncoder::new(&mut file).unwrap();
let mut img = enc.new_image::<CMYKA8>(2, 1).unwrap();
img.encoder().write_tag(tiff::tags::Tag::ExtraSamples, &[1u16][..]).unwrap();
img.write_data(&[255, 0, 0, 0, 255, 0, 0, 0, 128, 128]).unwrap();
let bytes = file.into_inner();
let naive = |[c, m, y, k]: [f32; 4]| [(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)];
let px = cmyka_tiff_rgba(&bytes, naive).expect("CMYKA reads");
assert_eq!(px.dimensions(), (2, 1));
assert_eq!(px.get_pixel(0, 0).0, [0, 255, 255, 255], "cyan, opaque");
assert_eq!(px.get_pixel(1, 0).0, [0, 0, 0, 128], "the premultiplied K divided back out: black at half alpha");
// Not CMYKA: not this reader's.
assert!(cmyka_tiff_rgba(&ImageBlob::cmyk_tiff(&inks()).unwrap().bytes, naive).is_none());
}
fn inks() -> Inks {
Inks::new(3, 2, vec![0, 40, 100, 0, 255, 0, 0, 0, 0, 255, 0, 0, 0, 0, 255, 0, 0, 0, 0, 255, 12, 34, 56, 78]).unwrap()
}
+108
View File
@@ -0,0 +1,108 @@
//! Inline graphics in text ([`crate::TextRun::inline`]): resolving each inline symbol's art bounds
//! from the document's symbols, which the text layout needs to size and place it.
use std::borrow::Cow;
use vectorcraft_geom::{Affine, Rect};
use crate::{Document, NodeId, NodeKind, TextObject};
/// The document key under which the app records each symbol's natural size (`{name: [w, h]}`).
pub const SYMBOL_SIZES: &str = "symbolSizes";
/// Half the side of the square symbol art is normalised to.
pub const SYMBOL_HALF: f64 = 10.0;
/// Usable art bounds: finite, non-degenerate.
fn usable(r: Option<Rect>) -> Option<Rect> {
r.filter(|r| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) && r.width() >= 0.0 && r.height() > 1e-9)
}
impl Document {
/// Symbol space → the symbol's art at its natural size. Symbols made in the app keep their
/// art in a 20 × 20 square around the origin and their size as drawn in the document's
/// `symbolSizes` (an instance is placed with that scale); other symbols are as they are.
pub fn symbol_natural_xf(&self, name: &str) -> Affine {
let size = self.unknown.get(SYMBOL_SIZES).and_then(|m| m.get(name)).and_then(|v| Some((v.get(0)?.as_f64()?, v.get(1)?.as_f64()?)));
match size {
Some((w, h)) if w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0 => {
Affine::scale_non_uniform(w / (2.0 * SYMBOL_HALF), h / (2.0 * SYMBOL_HALF))
}
_ => Affine::IDENTITY,
}
}
/// Visual bounds of the art of the symbol named `name` at its natural size (see
/// [`Self::symbol_natural_xf`]), if it exists and has a usable extent.
pub fn symbol_art_bounds(&self, name: &str) -> Option<Rect> {
let s = self.symbols.iter().find(|s| s.name == name)?;
let b = usable(s.art.visual_bounds().or_else(|| s.art.geometric_bounds()))?;
usable(Some(self.symbol_natural_xf(name).transform_rect_bbox(b)))
}
/// Does `t` have inline graphics whose resolved bounds differ from the document's symbols?
fn inline_stale(&self, t: &TextObject) -> bool {
t.runs.iter().filter_map(|r| r.inline.as_ref()).any(|a| a.bounds != self.symbol_art_bounds(&a.symbol))
}
/// `t` with its inline graphics' art bounds resolved from this document's symbols (borrowed
/// when nothing changes). Renderers and exporters lay text out through this, so they never
/// depend on the cached bounds being current.
pub fn inline_resolved<'a>(&self, t: &'a TextObject) -> Cow<'a, TextObject> {
if !self.inline_stale(t) {
return Cow::Borrowed(t);
}
let mut t = t.clone();
self.resolve_text_inline(&mut t);
Cow::Owned(t)
}
/// Fill in the art bounds of `t`'s inline graphics; returns whether any changed (the text's
/// cached layout bounds are then cleared).
pub fn resolve_text_inline(&self, t: &mut TextObject) -> bool {
let mut changed = false;
for r in &mut t.runs {
if let Some(a) = &mut r.inline {
let b = self.symbol_art_bounds(&a.symbol);
if a.bounds != b {
a.bounds = b;
changed = true;
}
}
}
if changed {
t.cached_bounds = None;
}
changed
}
/// Resolve the inline graphics of every text object in the layers (after opening a document
/// or changing its symbols). Returns the ids of the texts that changed: their cached layout
/// bounds were cleared and need recomputing.
pub fn resolve_inline_art(&mut self) -> Vec<NodeId> {
let mut stale = vec![];
self.walk(|n| {
if let NodeKind::Text(t) = &n.kind
&& t.runs.iter().any(|r| r.inline.is_some())
&& self.inline_stale(t)
{
stale.push(n.id);
}
});
let mut out = vec![];
for id in stale {
let Some(mut t) = self.node(id).and_then(|n| match &n.kind {
NodeKind::Text(t) => Some(t.clone()),
_ => None,
}) else {
continue;
};
if self.resolve_text_inline(&mut t)
&& let Some(n) = self.node_mut(id)
{
n.kind = NodeKind::Text(t);
out.push(id);
}
}
out
}
}
+13 -3
View File
@@ -14,6 +14,8 @@ pub mod corners;
pub mod graph;
pub mod hit;
pub mod inks;
mod inline;
pub use inline::{SYMBOL_HALF, SYMBOL_SIZES};
pub mod links;
pub mod live;
pub mod marks;
@@ -38,6 +40,7 @@ pub mod slices;
pub mod style_libs;
pub mod swatches;
pub mod text;
pub mod trace;
use std::collections::BTreeMap;
use std::sync::Arc;
@@ -83,10 +86,11 @@ 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, 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, 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;
pub use vectorcraft_geom as geom;
@@ -608,6 +612,11 @@ pub struct Document {
/// save time only, so changing the view never marks the document modified.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_view: Option<SavedView>,
/// The layers, sublayers and groups open in the Layers panel when the document was saved; they
/// reopen that way. `None`: the default, only the top-level layers open. Written at save time
/// only, like [`Document::last_view`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub layers_open: Option<Vec<NodeId>>,
/// Edit → Assign Profile: the profiles the document is tagged with (files before format v3
/// kept them in `unknown`, see [`Document::migrate_color_profiles`]).
#[serde(default, skip_serializing_if = "ColorProfiles::is_empty")]
@@ -700,6 +709,7 @@ impl Document {
metadata: DocMetadata::default(),
raster_effects: RasterEffectsSettings::default(),
last_view: None,
layers_open: None,
color_profiles: ColorProfiles::default(),
export_settings: Default::default(),
extra: Default::default(),
+2 -1
View File
@@ -463,7 +463,8 @@ pub struct Node {
/// Opacity mask (Transparency panel). Its art lives here, outside the layer tree.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mask: Option<Box<OpacityMask>>,
/// Image Trace object: `{preset, params}` it was traced with (the Image Trace panel shows them).
/// Image Trace object: `{preset, params, view?}` it was traced with (the Image Trace panel shows
/// them; `view` is its [`crate::TraceView`] id, absent for the tracing result).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trace: Option<Box<serde_json::Value>>,
/// Object → Text Wrap: area type below this object (in the same layer) flows around it.
+70 -3
View File
@@ -93,8 +93,9 @@ pub struct CharStyle {
}
/// Where a character smaller than the largest on its line lines up with it: on the Roman
/// baseline, or at the top (right, in vertical type), centre or bottom (left) of the ideographic
/// em boxes.
/// baseline, at the top (right, in vertical type), centre or bottom (left) of the ideographic
/// em boxes, or at the top (right) or bottom (left) of the ideographic character faces (ICF, the
/// average box of the ideographs inside the em box: OpenType's `icft` and `icfb` baselines).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum CharAlign {
@@ -103,6 +104,8 @@ pub enum CharAlign {
EmBoxTop,
EmBoxCenter,
EmBoxBottom,
IcfTop,
IcfBottom,
}
/// Superscript or subscript proportions in percent of the font size (Document Setup → Type).
@@ -758,6 +761,70 @@ pub struct TextStyleDef {
pub struct TextRun {
pub text: String,
pub style: CharStyle,
/// An inline graphic: the run is one [`INLINE_CHAR`] drawn as a document symbol that flows
/// with the text like a glyph (InDesign-style inline anchored object). Its `style` still sets
/// the size and tracking; fills and strokes don't apply to the art.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub inline: Option<InlineArt>,
}
impl TextRun {
/// A plain text run.
pub fn new(text: impl Into<String>, style: CharStyle) -> Self {
Self { text: text.into(), style, inline: None }
}
/// An inline graphic run showing `art`.
pub fn inline(art: InlineArt, style: CharStyle) -> Self {
Self { text: INLINE_CHAR.to_string(), style, inline: Some(art) }
}
}
/// The object replacement character: the plain text of an inline graphic run.
pub const INLINE_CHAR: char = '\u{FFFC}';
/// Largest inline graphic scale (× the run's size) accepted from input.
pub const INLINE_MAX_SCALE: f64 = 100.0;
/// A document symbol placed inline in text ([`TextRun::inline`]).
///
/// Placement: the art is scaled uniformly so its height (visual bounds) is `scale` × the run's
/// font size, its left edge at the pen position, and its vertical centre on the middle of the
/// run's cap height, raised by `baseline_shift` (points, positive = up). Centred on the cap
/// height the art lines up with capitals and figures (like a mana symbol in rules text) and, at
/// the default scale, stays inside the font's ascent and descent so the leading doesn't change.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct InlineArt {
/// Name of the document symbol ([`crate::Symbol::name`]).
pub symbol: String,
/// Art height as a multiple of the run's font size.
#[serde(default = "one")]
pub scale: f64,
/// Extra raise in points (positive = up).
#[serde(default)]
pub baseline_shift: f64,
/// The symbol art's visual bounds at its natural size, resolved from the document's symbols by
/// [`crate::Document::resolve_inline_art`] (not saved). `None`: not resolved or missing; the
/// layout then reserves a one-em square and nothing is drawn.
#[serde(skip)]
pub bounds: Option<Rect>,
}
fn one() -> f64 {
1.0
}
impl InlineArt {
pub fn new(symbol: impl Into<String>) -> Self {
Self { symbol: symbol.into(), scale: 1.0, baseline_shift: 0.0, bounds: None }
}
/// `scale`, made finite and positive (untrusted input).
pub fn safe_scale(&self) -> f64 {
if self.scale.is_finite() && self.scale > 0.0 { self.scale.min(INLINE_MAX_SCALE) } else { 1.0 }
}
/// `baseline_shift`, made finite (untrusted input).
pub fn safe_shift(&self) -> f64 {
if self.baseline_shift.is_finite() { self.baseline_shift.clamp(-1e5, 1e5) } else { 0.0 }
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -863,7 +930,7 @@ impl TextObject {
vertical: false,
kind: TextKind::Point,
xf: Affine::translate(origin.to_vec2()),
runs: vec![TextRun { text: text.into(), style }],
runs: vec![TextRun::new(text, style)],
para: ParaStyle::default(),
paras: Vec::new(),
area: AreaOptions::default(),
+92
View File
@@ -0,0 +1,92 @@
//! Image Trace objects' View: how a live trace draws on screen.
//!
//! An Image Trace object is a group whose `trace` holds `{preset, params, view}` (see
//! `imageTrace.make`). The view is a viewing aid only: exports, printing and Expand always use the
//! tracing result, whatever it is set to.
use crate::node::Node;
/// Image Trace › View.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum TraceView {
/// The traced shapes as they are filled.
#[default]
Result,
/// The traced shapes, with their outlines over them.
ResultWithOutlines,
/// Only the traced shapes' outlines.
Outlines,
/// The source image, with the traced shapes' outlines over it.
OutlinesWithSource,
/// Only the source image.
Source,
}
impl TraceView {
/// Panel order.
pub const ALL: [Self; 5] = [Self::Result, Self::ResultWithOutlines, Self::Outlines, Self::OutlinesWithSource, Self::Source];
/// The id used in command params and in the saved `trace` object.
pub fn id(self) -> &'static str {
match self {
Self::Result => "tracingResult",
Self::ResultWithOutlines => "tracingResultWithOutlines",
Self::Outlines => "outlines",
Self::OutlinesWithSource => "outlinesWithSourceImage",
Self::Source => "sourceImage",
}
}
/// The view an id names (any case).
pub fn from_id(id: &str) -> Option<Self> {
Self::ALL.into_iter().find(|v| v.id().eq_ignore_ascii_case(id))
}
/// The view an Image Trace object's `trace` settings ask for (the tracing result when they
/// name none, or an unknown one).
pub fn of(trace: &serde_json::Value) -> Self {
trace.get("view").and_then(serde_json::Value::as_str).and_then(Self::from_id).unwrap_or_default()
}
/// Whether the source image shows.
pub fn shows_source(self) -> bool {
matches!(self, Self::OutlinesWithSource | Self::Source)
}
/// Whether the traced shapes show filled.
pub fn shows_result(self) -> bool {
matches!(self, Self::Result | Self::ResultWithOutlines)
}
/// Whether the traced shapes' outlines show.
pub fn shows_outlines(self) -> bool {
matches!(self, Self::ResultWithOutlines | Self::Outlines | Self::OutlinesWithSource)
}
}
impl Node {
/// The View of an Image Trace object (the tracing result for anything else, or an unknown id).
pub fn trace_view(&self) -> TraceView {
self.trace.as_deref().map(TraceView::of).unwrap_or_default()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ids_round_trip_and_unknown_views_show_the_result() {
for v in TraceView::ALL {
assert_eq!(TraceView::from_id(v.id()), Some(v));
assert_eq!(TraceView::from_id(&v.id().to_ascii_uppercase()), Some(v));
assert!(v.shows_result() || v.shows_outlines() || v.shows_source(), "{v:?} shows something");
}
let mut g = Node::group(crate::NodeId(1), vec![]);
assert_eq!(g.trace_view(), TraceView::Result);
g.trace = Some(Box::new(serde_json::json!({"view": "outlines"})));
assert_eq!(g.trace_view(), TraceView::Outlines);
g.trace = Some(Box::new(serde_json::json!({"view": "sepia"})));
assert_eq!(g.trace_view(), TraceView::Result);
}
}
+28 -2
View File
@@ -5,7 +5,8 @@
//!
//! - **Geometry effects** (Distort & Transform, Path, Convert to Shape, Round Corners, Scribble,
//! Warp) rewrite a path: [`apply_geometry`] evaluates them in stack order.
//! - **Raster effects** (Drop Shadow, Inner/Outer Glow, Feather, Gaussian Blur) are described by
//! - **Raster effects** (Drop Shadow, Inner/Outer Glow, Feather, Gaussian Blur, and the
//! Photoshop-style filters of [`pixel`]: Radial Blur, Smart Blur, Unsharp Mask) are described by
//! [`raster_effects`] and painted by the renderer; [`outset`] says how far they reach beyond
//! the geometry.
//! - **Stroke geometry** ([`stroke`]): arrowheads, dash patterns and width profiles, shared by the
@@ -36,6 +37,7 @@ mod distort;
mod group;
mod live;
mod marks;
pub mod pixel;
mod raster;
mod reshape;
pub mod stroke;
@@ -61,6 +63,7 @@ pub use group::{
};
pub use live::{expand_live, expand_live_deep, expanded_live_group, text_outliner};
pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks};
pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace};
pub use raster::{RasterFx, outset, raster_effects};
pub use reshape::{expand_outlined, needs_outline, outline_art, outline_text, reshape};
pub use warp::{WarpStyle, warp_point};
@@ -118,7 +121,7 @@ fn lengths_of(id: &str) -> Lengths {
"distort.transform" => always(&["moveH", "moveV"]),
"path.offsetPath" => always(&["offset"]),
"path.outlineStroke" => always(&["width"]),
"stylize.roundCorners" | "stylize.feather" | "blur.gaussian" => always(&["radius"]),
"stylize.roundCorners" | "stylize.feather" | "blur.gaussian" | "blur.smart" | "sharpen.unsharpMask" => always(&["radius"]),
"stylize.scribble" => always(&["overlap", "strokeWidth", "spacing", "variation"]),
"stylize.dropShadow" => always(&["x", "y", "blur"]),
"stylize.innerGlow" | "stylize.outerGlow" => always(&["blur"]),
@@ -134,6 +137,7 @@ const SHAPE: &[&str] = &["Effect", "Convert to Shape"];
const STYLIZE: &[&str] = &["Effect", "Stylize"];
const WARP: &[&str] = &["Effect", "Warp"];
const BLUR: &[&str] = &["Effect", "Blur"];
const SHARPEN: &[&str] = &["Effect", "Sharpen"];
const PATHFINDER: &[&str] = &["Effect", "Pathfinder"];
const PLUGINS: &[&str] = &["Effect", "Plug-ins"];
const ADJUST: &[&str] = &["Effect", "Color Adjustments"];
@@ -265,6 +269,27 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
),
r("stylize.feather", "Feather…", STYLIZE, "{radius: pt (5)}", json!({"radius": 5.0})),
r("blur.gaussian", "Gaussian Blur…", BLUR, "{radius: pt (5)}", json!({"radius": 5.0})),
r(
"blur.radial",
"Radial Blur…",
BLUR,
"{amount: 1..100 (10; spin: an arc of `amount`°, zoom: the content scaled by up to about ±amount/2 %), method: \"spin\"|\"zoom\" (\"spin\"), quality: \"draft\"|\"good\"|\"best\" (\"good\"; 16, 64 or 256 samples)} blurs around the object's centre",
json!({"amount": 10.0, "method": "spin", "quality": "good"}),
),
r(
"blur.smart",
"Smart Blur…",
BLUR,
"{radius: pt 0.1..100 (3), threshold: levels 0.1..100 (25; only colours closer than this blur together, so edges stay sharp), quality: \"low\"|\"medium\"|\"high\" (\"medium\"; 5, 7 or 9 samples across)} (Normal mode)",
json!({"radius": 3.0, "threshold": 25.0, "quality": "medium"}),
),
r(
"sharpen.unsharpMask",
"Unsharp Mask…",
SHARPEN,
"{amount: % 1..500 (50), radius: pt 0.1..250 (1; σ of the blur edges are found against), threshold: levels 0..255 (0; smaller differences are left alone)}",
json!({"amount": 50.0, "radius": 1.0, "threshold": 0.0}),
),
];
v.extend([
g(
@@ -428,6 +453,7 @@ pub fn scale_effect(e: &mut Effect, s: f64) {
/// Is `id` a raster (painted) effect?
pub fn is_raster(id: &str) -> bool {
matches!(id, "stylize.dropShadow" | "stylize.innerGlow" | "stylize.outerGlow" | "stylize.feather" | "blur.gaussian")
|| PIXEL_EFFECTS.contains(&id)
}
/// Does `id` change geometry? (Crop Marks adds art of its own instead, [`crop_marks_art`]; colour
+129
View File
@@ -0,0 +1,129 @@
//! Blur › Radial Blur and Smart Blur.
use vectorcraft_geom::{Affine, Point, Vec2};
use super::{PixelSpace, Px16, box_blur, to8, to16};
/// Zoom's total spread, in log scale: `amount` 100 scales the content between 1/√2 and √2.
pub(super) fn zoom_extent(amount: f64) -> f64 {
(1.0 + amount / 100.0).ln()
}
/// `buf` (`w` × `h`) sampled bilinearly at pixel index position (`x`, `y`) (pixel centres at
/// whole coordinates); transparent outside.
fn bilinear(buf: &[Px16], w: usize, h: usize, x: f64, y: f64) -> [f32; 4] {
if !(x > -1.0 && y > -1.0 && x < w as f64 && y < h as f64) {
return [0.0; 4];
}
let (x0, y0) = (x.floor(), y.floor());
let (fx, fy) = ((x - x0) as f32, (y - y0) as f32);
let (xi, yi) = (x0 as isize, y0 as isize);
let at = |cx: isize, cy: isize| -> [f32; 4] {
if cx < 0 || cy < 0 || cx as usize >= w || cy as usize >= h {
return [0.0; 4];
}
buf.get(cy as usize * w + cx as usize).map_or([0.0; 4], |p| p.map(f32::from))
};
let (a, b, c, d) = (at(xi, yi), at(xi + 1, yi), at(xi, yi + 1), at(xi + 1, yi + 1));
let (wa, wb, wc, wd) = ((1.0 - fx) * (1.0 - fy), fx * (1.0 - fy), (1.0 - fx) * fy, fx * fy);
std::array::from_fn(|i| a[i] * wa + b[i] * wb + c[i] * wc + d[i] * wd)
}
/// Radial Blur: the average of the content turned (spin) or scaled (zoom) about the centre over
/// evenly spaced steps spanning `amount`. Each pass averages the previous result with itself
/// turned both ways by half the last step, so `passes` passes average 2^`passes` samples at two
/// lookups a pixel each.
pub(super) fn radial(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, amount: f64, zoom: bool, passes: u32) {
// The centre in pixel index coordinates (pixel centres at whole numbers).
let c = space.to_doc.inverse() * space.center - Vec2::new(0.5, 0.5);
if !(c.x.is_finite() && c.y.is_finite()) {
return;
}
let total = if zoom { zoom_extent(amount) } else { amount.to_radians() };
let about = |m: Affine| Affine::translate(c.to_vec2()) * m * Affine::translate(-c.to_vec2());
// A pixel's reflection in the view (one axis flipped) only turns the spin the other way,
// which a symmetric blur doesn't notice.
let step = |d: f64| if zoom { about(Affine::scale(d.exp())) } else { about(Affine::rotate(d)) };
let mut cur = to16(px);
let mut next = vec![[0u16; 4]; cur.len()];
for j in 1..=passes.min(12) {
let d = total / f64::powi(2.0, j as i32 + 1);
let (fwd, back) = (step(d), step(-d));
// Where a pixel's two samples lie moves by a fixed step along a row.
let (df, db) = (fwd * Point::new(1.0, 0.0) - fwd * Point::ZERO, back * Point::new(1.0, 0.0) - back * Point::ZERO);
for (y, row) in next.chunks_exact_mut(w).enumerate().take(h) {
let (mut f, mut b) = (fwd * Point::new(0.0, y as f64), back * Point::new(0.0, y as f64));
for out in row.iter_mut() {
let (s, t) = (bilinear(&cur, w, h, f.x, f.y), bilinear(&cur, w, h, b.x, b.y));
*out = std::array::from_fn(|i| ((s[i] + t[i]) * 0.5).round().clamp(0.0, 65535.0) as u16);
(f, b) = (f + df, b + db);
}
}
std::mem::swap(&mut cur, &mut next);
}
for (p, q) in px.iter_mut().zip(&cur) {
*p = q.map(to8);
}
}
/// The largest radius (pixels) Smart Blur looks over: farther neighbours are as good as gone.
const MAX_SMART: f64 = 4096.0;
/// Smart Blur (Normal mode): each pixel averaged with itself and the neighbours within `radius`
/// pixels whose premultiplied channels all lie within `threshold` levels of its own. Wide radii
/// look at `samples` × `samples` neighbours spread over the disc, read from a copy box-blurred
/// over the gaps between them so the blur stays smooth (a neighbour counts when both it and its
/// blurred surroundings are within the threshold, so at most a fringe under the threshold crosses
/// an edge).
pub(super) fn smart(px: &mut [[u8; 4]], w: usize, h: usize, radius: f64, threshold: f64, samples: u32) {
if radius.is_nan() || radius < 0.5 {
return;
}
let r = radius.min(MAX_SMART);
let n = samples.clamp(3, 15) as f64;
let k = r.round();
let (offsets, prefilter): (Vec<Vec2>, usize) = if 2.0 * k < n {
let k = k as i32;
((-k..=k).flat_map(|dy| (-k..=k).map(move |dx| Vec2::new(dx as f64, dy as f64))).collect(), 0)
} else {
let gap = 2.0 * r / (n - 1.0);
let grid = (0..n as i32).map(|i| -r + gap * i as f64);
(grid.clone().flat_map(|dy| grid.clone().map(move |dx| Vec2::new(dx.round(), dy.round()))).collect(), (gap / 2.0).floor() as usize)
};
let offsets: Vec<(isize, isize)> =
offsets.into_iter().filter(|o| o.hypot() <= r + 0.5 && *o != Vec2::ZERO).map(|o| (o.x as isize, o.y as isize)).collect();
let src = to16(px);
let blurred;
let near: &[Px16] = if prefilter > 0 {
let mut b = src.clone();
box_blur(&mut b, w, prefilter);
blurred = b;
&blurred
} else {
&src
};
let t = (threshold.clamp(0.0, 255.0) * 257.0) as i32;
for (y, (row, out)) in src.chunks_exact(w).zip(px.chunks_exact_mut(w)).enumerate().take(h) {
for (x, (c, o)) in row.iter().zip(out.iter_mut()).enumerate() {
let mut acc = c.map(u64::from);
let mut count = 1u64;
for (dx, dy) in &offsets {
let (sx, sy) = (x as isize + dx, y as isize + dy);
if sx < 0 || sy < 0 || sx as usize >= w || sy as usize >= h {
continue;
}
let i = sy as usize * w + sx as usize;
let (Some(s), Some(own)) = (near.get(i), src.get(i)) else { continue };
// The neighbour itself and its surroundings both close enough: edges stay sharp.
let close = |p: &Px16| p.iter().zip(c).all(|(a, b)| (*a as i32 - *b as i32).abs() <= t);
if close(own) && close(s) {
for (a, v) in acc.iter_mut().zip(s) {
*a += *v as u64;
}
count += 1;
}
}
*o = acc.map(|a| to8(((a + count / 2) / count).min(65535) as u16));
}
}
}
+297
View File
@@ -0,0 +1,297 @@
//! Photoshop-style raster effects (Effect › Blur › Radial Blur and Smart Blur, Sharpen › Unsharp
//! Mask): filters over the pixels of an object rendered alone, premultiplied RGBA8.
//!
//! - Every distance is in document units (points) and becomes pixels through the raster's
//! [`PixelSpace::px`], so an effect looks the same at any zoom and at any Document Raster Effects
//! Settings resolution (as Gaussian Blur's radius does).
//! - The work per pixel is bounded whatever the parameters: blurs are box approximations whose
//! cost doesn't grow with the radius, and sample counts come from the effect's quality.
//! - Beyond the raster's edges is transparency.
mod blur;
mod sharpen;
use serde_json::Value;
use vectorcraft_geom::{Affine, Point, Rect};
use crate::util::{num, text};
/// The Photoshop-style effect ids (all raster effects, see [`crate::is_raster`]).
pub const PIXEL_EFFECTS: [&str; 3] = ["blur.radial", "blur.smart", "sharpen.unsharpMask"];
/// A Photoshop-style raster effect with its parameters read and clamped to their ranges. Lengths
/// are in document units.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum PixelFx {
/// Blur › Radial Blur around the object's centre: along arcs (spin, an arc of `amount`°) or
/// along rays (zoom, the content scaled by up to about ±`amount`/2 %), averaging 2^`passes` samples.
RadialBlur { amount: f64, zoom: bool, passes: u32 },
/// Blur › Smart Blur (Normal mode): each pixel averaged with the neighbours within `radius`
/// whose colour differs from its own by no more than `threshold` levels, over a grid of
/// `samples` × `samples` neighbours at most.
SmartBlur { radius: f64, threshold: f64, samples: u32 },
/// Sharpen › Unsharp Mask: colours pushed away from a Gaussian blur of σ = `radius` by `amount`
/// (1 = 100 %), where they differ from it by at least `threshold` levels.
UnsharpMask { amount: f64, radius: f64, threshold: f64 },
}
/// Where a raster's pixels lie in the document.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PixelSpace {
/// Pixel coordinates (x right, y down, (0, 0) the top-left corner of the first pixel) →
/// document coordinates.
pub to_doc: Affine,
/// The size of a pixel in document units.
pub px: f64,
/// The object's centre (document coordinates): where Radial Blur blurs around.
pub center: Point,
}
/// Effect `id`'s parameters `p` (defaults merged in) as a [`PixelFx`]; `None` for other effects.
pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
let quality = |levels: [(&str, u32); 3], default: u32| {
let q = text(p, "quality", "");
levels.iter().find(|(name, _)| name.eq_ignore_ascii_case(q)).map_or(default, |(_, v)| *v)
};
Some(match id {
"blur.radial" => PixelFx::RadialBlur {
amount: num(p, "amount", 10.0).clamp(1.0, 100.0),
zoom: text(p, "method", "spin").eq_ignore_ascii_case("zoom"),
passes: quality([("draft", 4), ("good", 6), ("best", 8)], 6),
},
"blur.smart" => PixelFx::SmartBlur {
radius: num(p, "radius", 3.0).clamp(0.1, 100.0),
threshold: num(p, "threshold", 25.0).clamp(0.1, 100.0),
samples: quality([("low", 5), ("medium", 7), ("high", 9)], 7),
},
"sharpen.unsharpMask" => PixelFx::UnsharpMask {
amount: num(p, "amount", 50.0).clamp(1.0, 500.0) / 100.0,
radius: num(p, "radius", 1.0).clamp(0.1, 250.0),
threshold: num(p, "threshold", 0.0).clamp(0.0, 255.0),
},
_ => return None,
})
}
impl PixelFx {
/// How far beyond `bounds` (the content's, document units) the effect can paint.
pub fn outset(&self, bounds: Rect) -> f64 {
// From the centre to the farthest corner, and to the nearest side.
let far = 0.5 * bounds.width().hypot(bounds.height());
let near = 0.5 * bounds.width().min(bounds.height());
match *self {
// A rotated corner reaches the circle through the corners.
PixelFx::RadialBlur { zoom: false, .. } => (far - near).max(0.0),
PixelFx::RadialBlur { amount, zoom: true, .. } => far * (blur::zoom_extent(amount).exp() - 1.0),
PixelFx::SmartBlur { radius, .. } => radius,
PixelFx::UnsharpMask { .. } => 0.0,
}
}
/// How far (document units) from a pixel the content that decides it lies; `None` when that
/// is anywhere in the object (Radial Blur).
pub fn reach(&self) -> Option<f64> {
match *self {
PixelFx::RadialBlur { .. } => None,
PixelFx::SmartBlur { radius, .. } => Some(radius),
PixelFx::UnsharpMask { radius, .. } => Some(3.0 * radius),
}
}
/// Run the effect on `data`, `w` × `h` premultiplied RGBA8 pixels lying in the document as
/// `space` says. Does nothing when the sizes don't match.
pub fn apply(&self, data: &mut [u8], w: usize, h: usize, space: &PixelSpace) {
let Some(px) = pixels(data, w, h) else { return };
let to_px = |len: f64| len / space.px.max(1e-9);
match *self {
PixelFx::RadialBlur { amount, zoom, passes } => blur::radial(px, w, h, space, amount, zoom, passes),
PixelFx::SmartBlur { radius, threshold, samples } => blur::smart(px, w, h, to_px(radius), threshold, samples),
PixelFx::UnsharpMask { amount, radius, threshold } => sharpen::unsharp(px, w, h, amount, to_px(radius), threshold),
}
}
}
/// `data` as `w` × `h` RGBA pixels, `None` when it isn't that size (or is empty).
fn pixels(data: &mut [u8], w: usize, h: usize) -> Option<&mut [[u8; 4]]> {
let (px, rest) = data.as_chunks_mut::<4>();
(w > 0 && h > 0 && rest.is_empty() && w.checked_mul(h) == Some(px.len())).then_some(px)
}
/// The largest box radius the blurs use: wider boxes look the same on any raster we make.
const MAX_BOX: usize = 1 << 17;
/// Radii of three box blurs approximating a Gaussian of `sigma` pixels.
pub fn gauss_boxes(sigma: f64) -> [usize; 3] {
let sigma = if sigma.is_finite() { sigma.clamp(0.0, MAX_BOX as f64 / 2.0) } else { 0.0 };
let n = 3.0;
let w_ideal = (12.0 * sigma * sigma / n + 1.0).sqrt();
let mut wl = w_ideal.floor() as i64;
if wl % 2 == 0 {
wl -= 1;
}
let wu = wl + 2;
let m_ideal = (12.0 * sigma * sigma - n * (wl * wl) as f64 - 4.0 * n * wl as f64 - 3.0 * n) / (-4.0 * wl as f64 - 4.0);
let m = m_ideal.round() as i64;
let r = |i: i64| ((((if i < m { wl } else { wu }) - 1) / 2).max(0) as usize).min(MAX_BOX);
[r(0), r(1), r(2)]
}
/// One horizontal box blur of radius `r` over the rows of `w` values of a plane (running sum,
/// zero past the ends).
fn box_plane(src: &[f32], dst: &mut [f32], w: usize, r: usize) {
if r == 0 {
dst.copy_from_slice(src);
return;
}
let norm = 1.0 / (2 * r + 1) as f32;
for (row, out) in src.chunks_exact(w).zip(dst.chunks_exact_mut(w)) {
let mut acc: f32 = row.iter().take(r + 1).sum();
for (x, o) in out.iter_mut().enumerate() {
*o = acc * norm;
if let Some(v) = row.get(x + r + 1) {
acc += v;
}
if let Some(v) = x.checked_sub(r).and_then(|i| row.get(i)) {
acc -= v;
}
}
}
}
fn transpose<T: Copy>(src: &[T], dst: &mut [T], w: usize, h: usize) {
for (y, row) in src.chunks_exact(w).enumerate().take(h) {
for (x, v) in row.iter().enumerate() {
if let Some(d) = dst.get_mut(x * h + y) {
*d = *v;
}
}
}
}
/// Gaussian-blur a `w` × `h` plane of values in place (three box passes per axis, zero past the
/// edges). Does nothing when the sizes don't match.
pub fn blur_plane(a: &mut Vec<f32>, w: usize, h: usize, sigma: f64) {
if sigma < 0.2 || w == 0 || h == 0 || w.checked_mul(h) != Some(a.len()) {
return;
}
let boxes = gauss_boxes(sigma);
let mut tmp = vec![0.0; a.len()];
for (len, other) in [(w, h), (h, w)] {
for r in boxes {
box_plane(a, &mut tmp, len, r);
std::mem::swap(a, &mut tmp);
}
transpose(a, &mut tmp, len, other);
std::mem::swap(a, &mut tmp);
}
}
/// Premultiplied RGBA at 16 bits (×257): blurs and averages that keep their tones.
pub(super) type Px16 = [u16; 4];
pub(super) fn to16(px: &[[u8; 4]]) -> Vec<Px16> {
px.iter().map(|p| p.map(|v| v as u16 * 257)).collect()
}
pub(super) fn to8(v: u16) -> u8 {
((v as u32 + 128) / 257).min(255) as u8
}
fn add(acc: &mut [u64; 4], p: &Px16) {
for (a, v) in acc.iter_mut().zip(p) {
*a += *v as u64;
}
}
fn sub(acc: &mut [u64; 4], p: &Px16) {
for (a, v) in acc.iter_mut().zip(p) {
*a = a.saturating_sub(*v as u64);
}
}
fn mean(acc: &[u64; 4], n: u64) -> Px16 {
acc.map(|a| ((a + n / 2) / n.max(1)).min(65535) as u16)
}
/// One box blur of radius `r` along the rows of `w` pixels (transparent past the ends).
fn box_rows(src: &[Px16], dst: &mut [Px16], w: usize, r: usize) {
let n = 2 * r as u64 + 1;
for (row, out) in src.chunks_exact(w).zip(dst.chunks_exact_mut(w)) {
let mut acc = [0u64; 4];
row.iter().take(r + 1).for_each(|p| add(&mut acc, p));
for (x, o) in out.iter_mut().enumerate() {
*o = mean(&acc, n);
if let Some(p) = row.get(x + r + 1) {
add(&mut acc, p);
}
if let Some(p) = x.checked_sub(r).and_then(|i| row.get(i)) {
sub(&mut acc, p);
}
}
}
}
/// One box blur of radius `r` down the columns of `w`-wide rows (transparent past the ends), row
/// by row with a running sum per column.
fn box_cols(src: &[Px16], dst: &mut [Px16], w: usize, r: usize) {
let n = 2 * r as u64 + 1;
let rows: Vec<&[Px16]> = src.chunks_exact(w).collect();
let mut acc = vec![[0u64; 4]; w];
let add_row = |acc: &mut [[u64; 4]], row: &[Px16]| acc.iter_mut().zip(row).for_each(|(a, p)| add(a, p));
rows.iter().take(r + 1).for_each(|row| add_row(&mut acc, row));
for (y, out) in dst.chunks_exact_mut(w).enumerate() {
for (o, a) in out.iter_mut().zip(&acc) {
*o = mean(a, n);
}
if let Some(row) = rows.get(y + r + 1) {
add_row(&mut acc, row);
}
if let Some(row) = y.checked_sub(r).and_then(|i| rows.get(i)) {
acc.iter_mut().zip(*row).for_each(|(a, p)| sub(a, p));
}
}
}
/// Box-blur `w`-wide rows of pixels in place with radius `r` along both axes.
pub(super) fn box_blur(px: &mut [Px16], w: usize, r: usize) {
if r == 0 || w == 0 {
return;
}
let mut tmp = vec![[0u16; 4]; px.len()];
box_rows(px, &mut tmp, w, r);
box_cols(&tmp, px, w, r);
}
/// Gaussian-blur `w`-wide rows of premultiplied pixels in place (σ = `sigma` pixels, three box
/// passes per axis, transparent past the edges).
pub(super) fn gaussian(px: &mut [Px16], w: usize, sigma: f64) {
if sigma < 0.2 || w == 0 {
return;
}
let mut tmp = vec![[0u16; 4]; px.len()];
for r in gauss_boxes(sigma) {
box_rows(px, &mut tmp, w, r);
px.copy_from_slice(&tmp);
}
for r in gauss_boxes(sigma) {
box_cols(px, &mut tmp, w, r);
px.copy_from_slice(&tmp);
}
}
/// Gaussian-blur `data`, `w` × `h` premultiplied RGBA8 pixels, in place (σ = `sigma` pixels). Does
/// nothing when the sizes don't match.
pub fn gaussian_rgba(data: &mut [u8], w: usize, h: usize, sigma: f64) {
if let Some(px) = pixels(data, w, h)
&& sigma >= 0.2
{
let mut wide = to16(px);
gaussian(&mut wide, w, sigma);
for (p, q) in px.iter_mut().zip(&wide) {
*p = q.map(to8);
}
}
}
#[cfg(test)]
mod tests;
+31
View File
@@ -0,0 +1,31 @@
//! Sharpen › Unsharp Mask.
use super::{gaussian, to16};
/// Unsharp Mask: each colour channel pushed away from a Gaussian blur of the pixels (σ = `sigma`
/// pixels) by `amount` times their difference, where that difference reaches `threshold` levels.
/// The blur is premultiplied, so edges against transparency don't darken; coverage stays as it is.
pub(super) fn unsharp(px: &mut [[u8; 4]], w: usize, h: usize, amount: f64, sigma: f64, threshold: f64) {
if sigma.is_nan() || sigma < 0.2 || amount.is_nan() || amount <= 0.0 || h == 0 {
return;
}
let mut blurred = to16(px);
gaussian(&mut blurred, w, sigma);
let (amount, threshold) = (amount as f32, threshold as f32);
for (p, b) in px.iter_mut().zip(&blurred) {
let a = p[3] as f32;
if a == 0.0 {
continue;
}
for i in 0..3 {
let own = p[i] as f32 * 255.0 / a;
let around = if b[3] > 0 { b[i] as f32 * 255.0 / b[3] as f32 } else { own };
let diff = own - around;
if diff.abs() < threshold || diff == 0.0 {
continue;
}
let v = (own + amount * diff).clamp(0.0, 255.0);
p[i] = (v * a / 255.0).round().clamp(0.0, 255.0) as u8;
}
}
}
+231
View File
@@ -0,0 +1,231 @@
use serde_json::json;
use vectorcraft_doc::Effect;
use vectorcraft_geom::{Affine, Point, Rect};
use super::*;
use crate::{RasterFx, raster_effects};
/// A `w` × `h` raster whose pixels are document points (origin top-left).
fn space(w: usize, h: usize) -> PixelSpace {
PixelSpace { to_doc: Affine::IDENTITY, px: 1.0, center: Point::new(w as f64 / 2.0, h as f64 / 2.0) }
}
fn image(w: usize, h: usize, f: impl Fn(usize, usize) -> [u8; 4]) -> Vec<u8> {
let mut out = Vec::with_capacity(w * h * 4);
for y in 0..h {
for x in 0..w {
out.extend(f(x, y));
}
}
out
}
fn at(d: &[u8], w: usize, x: usize, y: usize) -> [u8; 4] {
let i = (y * w + x) * 4;
[d[i], d[i + 1], d[i + 2], d[i + 3]]
}
fn fx(id: &str, p: serde_json::Value) -> PixelFx {
let e = Effect { id: id.into(), params: p, visible: true };
match raster_effects(&[e]).first() {
Some(RasterFx::Pixel(p)) => *p,
other => panic!("{id}: {other:?}"),
}
}
fn grey(v: u8) -> [u8; 4] {
[v, v, v, 255]
}
#[test]
fn params_take_defaults_and_stay_in_range() {
assert_eq!(fx("blur.radial", json!({})), PixelFx::RadialBlur { amount: 10.0, zoom: false, passes: 6 });
assert_eq!(
fx("blur.radial", json!({"amount": 1e308, "method": "ZOOM", "quality": "best"})),
PixelFx::RadialBlur { amount: 100.0, zoom: true, passes: 8 }
);
assert_eq!(fx("blur.radial", json!({"amount": -5, "quality": "nonsense"})), PixelFx::RadialBlur { amount: 1.0, zoom: false, passes: 6 });
assert_eq!(fx("blur.smart", json!({})), PixelFx::SmartBlur { radius: 3.0, threshold: 25.0, samples: 7 });
assert_eq!(
fx("blur.smart", json!({"radius": "1e999", "threshold": -1, "quality": "low"})),
PixelFx::SmartBlur { radius: 3.0, threshold: 0.1, samples: 5 }
);
assert_eq!(fx("sharpen.unsharpMask", json!({})), PixelFx::UnsharpMask { amount: 0.5, radius: 1.0, threshold: 0.0 });
assert_eq!(
fx("sharpen.unsharpMask", json!({"amount": 1e9, "radius": 0, "threshold": 1e9})),
PixelFx::UnsharpMask { amount: 5.0, radius: 0.1, threshold: 255.0 }
);
for id in PIXEL_EFFECTS {
assert!(crate::is_raster(id) && crate::effect_info(id).is_some_and(|e| e.raster), "{id}");
}
}
#[test]
fn outsets_follow_the_object() {
let b = Rect::new(0.0, 0.0, 100.0, 50.0);
let spin = fx("blur.radial", json!({}));
let far = 0.5 * 100f64.hypot(50.0);
assert!((spin.outset(b) - (far - 25.0)).abs() < 1e-9);
assert!(fx("blur.radial", json!({"method": "zoom", "amount": 100})).outset(b) > 0.4 * far);
assert_eq!(fx("blur.smart", json!({"radius": 4})).outset(b), 4.0);
assert_eq!(fx("sharpen.unsharpMask", json!({})).outset(b), 0.0);
assert_eq!(spin.reach(), None);
assert_eq!(fx("sharpen.unsharpMask", json!({"radius": 2})).reach(), Some(6.0));
}
#[test]
fn wrong_sizes_and_empty_rasters_are_left_alone() {
for id in PIXEL_EFFECTS {
let f = fx(id, json!({}));
let mut d = vec![200u8; 4 * 10];
f.apply(&mut d, 3, 3, &space(3, 3));
f.apply(&mut d, 0, 10, &space(0, 10));
f.apply(&mut [], 0, 0, &space(0, 0));
assert!(d.iter().all(|v| *v == 200), "{id}");
}
}
#[test]
fn extreme_parameters_finish() {
let (w, h) = (40, 30);
let src = image(w, h, |x, y| if (x / 5 + y / 5) % 2 == 0 { grey(250) } else { [0, 0, 0, 0] });
let tiny = PixelSpace { px: 1e-9, ..space(w, h) };
let huge = PixelSpace { px: 1e9, center: Point::new(f64::MAX, -1e300), ..space(w, h) };
for id in PIXEL_EFFECTS {
for p in [json!({"amount": 1e308, "radius": 1e308, "threshold": 1e308, "quality": "best"}), json!({"amount": 0, "radius": 0})] {
for s in [tiny, huge, space(w, h)] {
let mut d = src.clone();
fx(id, p.clone()).apply(&mut d, w, h, &s);
}
}
}
}
#[test]
fn every_effect_is_deterministic() {
let (w, h) = (32, 24);
let src = image(w, h, |x, y| {
let a = if x > 4 { 255 } else { 128 };
[((x * 8) as u8).min(a), ((y * 10) as u8).min(a), ((x * y % 256) as u8).min(a), a]
});
for id in PIXEL_EFFECTS {
let f = fx(id, json!({"amount": 40, "radius": 3, "threshold": 40}));
let (mut a, mut b) = (src.clone(), src.clone());
f.apply(&mut a, w, h, &space(w, h));
f.apply(&mut b, w, h, &space(w, h));
assert_eq!(a, b, "{id}");
assert_ne!(a, src, "{id} changes the image");
// Premultiplied stays premultiplied.
assert!(a.chunks_exact(4).all(|p| p[0] <= p[3] && p[1] <= p[3] && p[2] <= p[3]), "{id}");
}
}
#[test]
fn spin_blur_smears_along_arcs_and_keeps_the_centre() {
let (w, h) = (41, 41);
// A white dot 12 px right of the centre (20.5, 20.5), and one on the centre.
let src = image(w, h, |x, y| if (x == 32 || x == 20) && y == 20 { grey(255) } else { [0, 0, 0, 0] });
let mut d = src.clone();
fx("blur.radial", json!({"amount": 60, "quality": "best"})).apply(&mut d, w, h, &space(w, h));
// 60° of arc around the centre: up to 30° either way, 12 px out: (20.5 + 12 cos 20°, 20.5 ± 12 sin 20°).
assert!(at(&d, w, 31, 16)[3] > 0 && at(&d, w, 31, 24)[3] > 0, "the dot spreads along its circle");
assert_eq!(at(&d, w, 32, 10)[3], 0, "not beyond the arc");
assert_eq!(at(&d, w, 26, 20)[3], 0, "not towards the centre");
assert!(at(&d, w, 20, 20)[3] > 100, "the centre barely moves: {:?}", at(&d, w, 20, 20));
// Zoom smears along the ray instead.
let mut z = src.clone();
fx("blur.radial", json!({"amount": 60, "method": "zoom", "quality": "best"})).apply(&mut z, w, h, &space(w, h));
assert!(at(&z, w, 35, 20)[3] > 0 && at(&z, w, 29, 20)[3] > 0, "the dot streaks along the ray");
assert_eq!(at(&z, w, 32, 16)[3], 0, "not across it");
}
#[test]
fn smart_blur_smooths_noise_and_keeps_edges() {
let (w, h) = (40, 20);
// Left: grey 100 with ±6 noise; right: white.
let src = image(w, h, |x, y| if x < 20 { grey(if (x + y) % 2 == 0 { 94 } else { 106 }) } else { grey(255) });
let mut d = src.clone();
fx("blur.smart", json!({"radius": 3, "threshold": 25})).apply(&mut d, w, h, &space(w, h));
let v = at(&d, w, 8, 10)[0];
assert!((97..=103).contains(&v), "noise smoothed: {v}");
assert!(at(&d, w, 19, 10)[0] < 120, "the edge's dark side doesn't take the white");
assert_eq!(at(&d, w, 20, 10)[0], 255, "the edge's white side stays white");
// A threshold above the step blurs across it.
let mut all = src.clone();
fx("blur.smart", json!({"radius": 3, "threshold": 100})).apply(&mut all, w, h, &space(w, h));
assert_eq!(at(&all, w, 20, 10)[0], 255, "a 155-level step is still past 100");
}
#[test]
fn unsharp_mask_overshoots_at_edges() {
let (w, h) = (40, 10);
let src = image(w, h, |x, _| grey(if x < 20 { 100 } else { 150 }));
let mut d = src.clone();
fx("sharpen.unsharpMask", json!({"amount": 100, "radius": 2})).apply(&mut d, w, h, &space(w, h));
assert!(at(&d, w, 19, 5)[0] < 95, "dark side darker: {:?}", at(&d, w, 19, 5));
assert!(at(&d, w, 20, 5)[0] > 155, "light side lighter: {:?}", at(&d, w, 20, 5));
assert_eq!(at(&d, w, 5, 5), grey(100), "flat areas stay");
assert_eq!(at(&d, w, 35, 5), grey(150));
// Differences under the threshold are left alone.
let mut t = src.clone();
fx("sharpen.unsharpMask", json!({"amount": 100, "radius": 2, "threshold": 60})).apply(&mut t, w, h, &space(w, h));
assert_eq!(t, src);
// Coverage stays, and a shape's edge against transparency doesn't darken.
let shape = image(w, h, |x, _| if x < 20 { [200, 0, 0, 255] } else { [0, 0, 0, 0] });
let mut s = shape.clone();
fx("sharpen.unsharpMask", json!({"amount": 300, "radius": 3})).apply(&mut s, w, h, &space(w, h));
assert_eq!(s, shape);
}
#[test]
fn lengths_are_document_units() {
// The same art at twice the resolution, sharpened with the same radius in points: the
// overshoot spans twice the pixels, the same distance.
let edge = |scale: usize| image(40 * scale, 4, move |x, _| grey(if x < 20 * scale { 100 } else { 150 }));
let run = |scale: usize| {
let mut d = edge(scale);
let s = PixelSpace { to_doc: Affine::scale(1.0 / scale as f64), px: 1.0 / scale as f64, center: Point::ZERO };
fx("sharpen.unsharpMask", json!({"amount": 100, "radius": 2})).apply(&mut d, 40 * scale, 4, &s);
(0..40 * scale).filter(|x| at(&d, 40 * scale, *x, 2)[0] < 99).count()
};
let (one, two) = (run(1), run(2));
assert!(one > 1 && (two as f64 / one as f64 - 2.0).abs() < 0.5, "{one} px at 1×, {two} px at 2×");
}
#[test]
fn gaussian_and_plane_blurs_keep_their_mass() {
let (w, h) = (30, 30);
let mut d = image(w, h, |x, y| if (10..20).contains(&x) && (10..20).contains(&y) { grey(255) } else { [0, 0, 0, 0] });
let before: u32 = d.chunks_exact(4).map(|p| p[3] as u32).sum();
gaussian_rgba(&mut d, w, h, 2.0);
let after: u32 = d.chunks_exact(4).map(|p| p[3] as u32).sum();
assert!((before as f64 - after as f64).abs() / (before as f64) < 0.02, "{before} → {after}");
assert!(at(&d, w, 9, 15)[3] > 0 && at(&d, w, 10, 15)[3] < 255);
let mut plane: Vec<f32> = (0..w * h).map(|i| if i == 15 * w + 15 { 100.0 } else { 0.0 }).collect();
blur_plane(&mut plane, w, h, 2.0);
assert!((plane.iter().sum::<f32>() - 100.0).abs() < 0.5 && plane[15 * w + 13] > 0.0 && plane[13 * w + 15] > 0.0);
}
/// Timings on a 2000 × 2000 raster (`cargo test -p vectorcraft-effects --release -- --ignored
/// --nocapture pixel_timings`).
#[test]
#[ignore]
fn pixel_timings() {
let (w, h) = (2000, 2000);
let src = image(w, h, |x, y| [(x % 256) as u8, (y % 256) as u8, ((x ^ y) % 256) as u8, 255]);
let s = PixelSpace { to_doc: Affine::IDENTITY, px: 1.0, center: Point::new(1000.0, 1000.0) };
let cases = [
("blur.radial", json!({"quality": "draft"})),
("blur.radial", json!({"quality": "good"})),
("blur.radial", json!({"quality": "best", "method": "zoom"})),
("blur.smart", json!({"quality": "low"})),
("blur.smart", json!({"quality": "high", "radius": 30})),
("sharpen.unsharpMask", json!({"radius": 20})),
];
for (id, p) in cases {
let mut d = src.clone();
let t = std::time::Instant::now();
fx(id, p.clone()).apply(&mut d, w, h, &s);
println!("{id} {p}: {:?}", t.elapsed());
}
}
+14 -7
View File
@@ -4,7 +4,10 @@ use serde_json::Value;
use vectorcraft_doc::Effect;
use vectorcraft_doc::color::{BlendMode, Color};
use vectorcraft_geom::Rect;
use crate::merged_params;
use crate::pixel::{self, PixelFx};
use crate::util::*;
/// A painted effect, in document units. `blur` is Illustrator's blur distance; the renderer uses
@@ -40,6 +43,8 @@ pub enum RasterFx {
GaussianBlur {
radius: f64,
},
/// A Photoshop-style filter over the object's pixels (Radial Blur, Unsharp Mask…).
Pixel(PixelFx),
}
impl RasterFx {
@@ -47,13 +52,15 @@ impl RasterFx {
pub fn is_below(&self) -> bool {
matches!(self, RasterFx::DropShadow { .. } | RasterFx::OuterGlow { .. })
}
/// How far this effect reaches beyond the geometry.
pub fn outset(&self) -> f64 {
/// How far this effect reaches beyond `bounds`, what it paints around (Radial Blur reaches
/// further around larger objects).
pub fn outset(&self, bounds: Rect) -> f64 {
match self {
RasterFx::DropShadow { dx, dy, blur, .. } => dx.abs().max(dy.abs()) + 1.5 * blur,
RasterFx::OuterGlow { blur, .. } => 1.5 * blur,
RasterFx::InnerGlow { .. } | RasterFx::Feather { .. } => 0.0,
RasterFx::GaussianBlur { radius } => 1.5 * radius,
RasterFx::Pixel(p) => p.outset(bounds),
}
}
}
@@ -113,14 +120,14 @@ pub fn raster_effects(effects: &[Effect]) -> Vec<RasterFx> {
},
"stylize.feather" => RasterFx::Feather { radius: num(&p, "radius", 5.0).clamp(0.0, 1000.0) },
"blur.gaussian" => RasterFx::GaussianBlur { radius: num(&p, "radius", 5.0).clamp(0.0, 1000.0) },
_ => return None,
id => RasterFx::Pixel(pixel::parse(id, &p)?),
})
})
.collect()
}
/// How far the visible raster effects of `effects` paint beyond the (effected) geometry, in
/// document units. Geometry effects are accounted for by evaluating them.
pub fn outset(effects: &[Effect]) -> f64 {
raster_effects(effects).iter().map(RasterFx::outset).fold(0.0, f64::max)
/// How far the visible raster effects of `effects` paint beyond `bounds`, the (effected)
/// geometry's, in document units. Geometry effects are accounted for by evaluating them.
pub fn outset(effects: &[Effect], bounds: Rect) -> f64 {
raster_effects(effects).iter().map(|fx| fx.outset(bounds)).fold(0.0, f64::max)
}
+2 -2
View File
@@ -301,8 +301,8 @@ fn raster_effects_parse_and_outset() {
other => panic!("{other:?}"),
}
assert!(matches!(r[2], RasterFx::InnerGlow { center: true, .. }));
assert!(close(outset(&list), 10.0 + 9.0, 1e-9));
assert_eq!(outset(&[]), 0.0);
assert!(close(outset(&list, vectorcraft_geom::Rect::ZERO), 10.0 + 9.0, 1e-9));
assert_eq!(outset(&[], vectorcraft_geom::Rect::ZERO), 0.0);
}
#[test]
+3
View File
@@ -25,6 +25,7 @@ vectorcraft-cad = { workspace = true }
vectorcraft-eps = { workspace = true }
vectorcraft-metafile = { workspace = true }
vectorcraft-plugins = { workspace = true }
vectorcraft-affinity = { workspace = true }
image = { workspace = true }
flate2 = { workspace = true }
kurbo = { workspace = true }
@@ -34,6 +35,8 @@ thiserror = { workspace = true }
log = { workspace = true }
[dev-dependencies]
vectorcraft-affinity = { workspace = true, features = ["synth"] }
crc32fast = { workspace = true }
vectorcraft-testkit = { workspace = true }
proptest = { workspace = true }
wat = { workspace = true }
+2 -2
View File
@@ -18,10 +18,10 @@ use super::edit::selected_roots;
use super::*;
use crate::EngineError;
const SIZES_KEY: &str = "symbolSizes";
const SIZES_KEY: &str = vectorcraft_doc::SYMBOL_SIZES;
const CURRENT_SYMBOL: &str = "currentSymbol";
/// Half the instance box `Node::geometric_bounds` uses for symbol instances.
const HALF: f64 = 10.0;
const HALF: f64 = vectorcraft_doc::SYMBOL_HALF;
pub fn specs() -> Vec<CommandSpec> {
vec![
+96 -7
View File
@@ -14,8 +14,8 @@ use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::appearance::{AppearanceItem, FillLayer, StrokeLayer};
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection};
use vectorcraft_doc::appearance::{AppearanceItem, FillLayer, LineCap, LineJoin, StrokeLayer};
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection, TraceView};
use vectorcraft_geom::{FillRule, PathData, Point, Rect, SubPath};
use vectorcraft_pathops as po;
use vectorcraft_tools::builder::{
@@ -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, colors: 2-256, paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite, snapCurvesToLines}} → {id, paths, anchors, colors}",
"{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}",
has_selection_or_ids,
|s, p| trace_make(s, p, false)
),
@@ -151,6 +151,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_selection_or_ids,
trace_expand
),
cmd!(
"imageTrace.setView",
"Image Trace View",
[],
None,
"{id?|ids?: Image Trace objects (default: selection), view: \"tracingResult\"|\"tracingResultWithOutlines\"|\"outlines\"|\"outlinesWithSourceImage\"|\"sourceImage\"} how they draw on screen; exports, printing and Expand keep the tracing result → {ids, view}",
has_selection_or_ids,
trace_set_view
),
cmd!(query "imageTrace.presets", "Image Trace Presets", [], None, "{} → {presets: [{name, params}]}", always, |_, _| {
Ok(json!({ "presets": tr::presets().into_iter().map(|(n, p)| json!({ "name": n, "params": p })).collect::<Vec<_>>() }))
}),
@@ -639,6 +648,13 @@ fn is_trace_group(n: &Node) -> bool {
&& n.children().is_some_and(|c| c.first().is_some_and(|i| matches!(i.kind, NodeKind::Image(_))))
}
/// The `view` param ([`TraceView`] id), if given.
fn view_param(cmd: &str, p: &Value) -> Result<Option<TraceView>> {
let Some(v) = p.get("view").filter(|v| !v.is_null()) else { return Ok(None) };
let ids = || TraceView::ALL.map(TraceView::id).join(", ");
v.as_str().and_then(TraceView::from_id).map(Some).ok_or_else(|| bad(cmd, format!("view must be one of {}", ids())))
}
fn trace_params(p: &Value) -> Result<tr::TraceParams> {
let name = str_param(p, "preset").unwrap_or("Default");
let base = tr::preset(name).ok_or_else(|| bad("imageTrace.make", format!("unknown preset `{name}` (see imageTrace.presets)")))?;
@@ -648,7 +664,38 @@ fn trace_params(p: &Value) -> Result<tr::TraceParams> {
for (k, val) in src {
o.insert(k.clone(), val.clone());
}
serde_json::from_value(v).map_err(|e| bad("imageTrace.make", e.to_string()))
let params: tr::TraceParams = serde_json::from_value(v).map_err(|e| bad("imageTrace.make", e.to_string()))?;
if !params.fills && !params.strokes {
return Err(bad("imageTrace.make", "Create needs fills, strokes or both"));
}
if !(params.stroke_width.is_finite() && params.stroke_width > 0.0) {
return Err(bad("imageTrace.make", "strokeWidth must be a positive number of pixels"));
}
Ok(params)
}
/// Document Library in Color mode: look the library's colours up into `params.swatches` → each
/// one as traced (sRGB) with the colour to fill with.
fn trace_library(s: &Session, params: &mut tr::TraceParams) -> Result<Vec<([u8; 3], Color)>> {
const C: &str = "imageTrace.make";
if params.mode != tr::Mode::Color || params.palette != tr::Palette::DocumentLibrary {
return Ok(vec![]);
}
let key = params.library.as_str();
let colors = super::swatchlib::library_colors(s, key)
.ok_or_else(|| bad(C, format!("no swatch library `{key}` (see swatch.library.list; \"document\": the document's swatches)")))?;
if colors.is_empty() {
return Err(bad(C, format!("`{key}` has no colours")));
}
let library: Vec<([u8; 3], Color)> = colors
.into_iter()
.map(|c| {
let [r, g, b, _] = c.to_rgba8(1.0);
([r, g, b], c)
})
.collect();
params.swatches = library.iter().map(|l| l.0).collect();
Ok(library)
}
/// The most anchor points an Image Trace may make: a photo traced at high fidelity can make
@@ -658,7 +705,8 @@ pub const MAX_TRACE_ANCHORS: usize = 2_000_000;
fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
const C: &str = "imageTrace.make";
let params = trace_params(p)?;
let mut params = trace_params(p)?;
let view = view_param(C, p)?;
let params_json = serde_json::to_value(&params).map_err(|e| EngineError::Other(e.to_string()))?;
// The preset's name if the parameters are exactly that preset's, else "Custom".
let named = str_param(p, "preset").unwrap_or("Default");
@@ -687,11 +735,14 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
};
let blob = s.doc()?.doc.images.get(&img.key).ok_or_else(|| EngineError::Other(format!("image data `{}` is missing", img.key)))?;
let raster = tr::Raster::decode(&blob.bytes).map_err(|e| EngineError::Other(e.to_string()))?;
let library = trace_library(s, &mut params)?;
let res = tr::trace_within(&raster, &params, MAX_TRACE_ANCHORS).map_err(|e| EngineError::Other(e.to_string()))?;
// Pixel space of the decoded raster → the image object's pixel space → document.
let sx = img.width.max(1) as f64 / raster.width.max(1) as f64;
let sy = img.height.max(1) as f64 / raster.height.max(1) as f64;
let xf = img.xf * vectorcraft_geom::Affine::scale_non_uniform(sx, sy);
// Document units per raster pixel (stroke widths).
let unit = xf.determinant().abs().sqrt();
let (npaths, anchors, colors) = (res.paths.len(), res.anchor_count(), res.palette.len());
let label = if expand { "Image Trace (Make and Expand)" } else { "Image Trace" };
let id = s.edit(label, |d, sel| {
@@ -714,14 +765,30 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
for tp in res.paths {
let path = tp.path.transformed(xf);
let mut n = shape_node(d, path, None);
n.appearance = fill_only(Paint::solid(Color::rgb8(tp.color[0], tp.color[1], tp.color[2])));
// A library colour fills with the swatch's own colour (its model kept).
let color = library.iter().find(|(rgb, _)| *rgb == tp.color).map_or_else(|| Color::rgb8(tp.color[0], tp.color[1], tp.color[2]), |l| l.1);
let paint = Paint::solid(color);
n.appearance = match tp.stroke {
// Traced lines: round ends and corners, as drawn lines have.
Some(width) => Appearance {
items: vec![AppearanceItem::Stroke(StrokeLayer {
cap: LineCap::Round,
join: LineJoin::Round,
..StrokeLayer::new(paint, width * unit)
})],
..Default::default()
},
None => fill_only(paint),
};
children.push(Arc::new(n));
}
let gid = d.alloc_id();
let mut g = Node::group(gid, children);
g.name = if expand { None } else { Some(TRACE_NAME.into()) };
if !expand {
g.trace = Some(Box::new(json!({ "preset": preset_label, "params": params_json })));
// Tracing again keeps the object's view unless another is asked for.
let view = view.unwrap_or_else(|| src.trace_view());
g.trace = Some(Box::new(json!({ "preset": preset_label, "params": params_json, "view": view.id() })));
}
let (par, idx, _) = d.position(target).ok_or(EngineError::NoNode(target))?;
d.remove(target)?;
@@ -741,6 +808,28 @@ fn trace_targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
Ok(v)
}
fn trace_set_view(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "imageTrace.setView";
let view = view_param(C, p)?.ok_or_else(|| bad(C, "view is required"))?;
let targets = trace_targets(s, p)?;
let d = &s.doc()?.doc;
if targets.iter().all(|g| d.node(*g).is_some_and(|n| n.trace_view() == view)) {
return Ok(json!({ "ids": ids_json(&targets), "view": view.id() }));
}
s.edit("Image Trace View", |d, _| {
for g in &targets {
let n = d.node_mut(*g).ok_or(EngineError::NoNode(*g))?;
if let Some(t) = n.trace.as_deref_mut().and_then(Value::as_object_mut) {
t.insert("view".into(), json!(view.id()));
} else {
n.trace = Some(Box::new(json!({ "preset": "Custom", "view": view.id() })));
}
}
Ok(())
})?;
Ok(json!({ "ids": ids_json(&targets), "view": view.id() }))
}
fn trace_release(s: &mut Session, p: &Value) -> Result<Value> {
let targets = trace_targets(s, p)?;
let ids = s.edit("Release Image Trace", |d, sel| {
+23 -1
View File
@@ -170,8 +170,10 @@ impl Session {
return None;
}
let d = self.clipboard.to_document();
// SVG has no filters for the Photoshop-style effects: their objects go in as images.
let flat = super::rasterfx::flatten_pixel_effects(&d);
Some(vectorcraft_svg::export(
&d,
flat.as_ref().unwrap_or(&d),
&vectorcraft_svg::ExportOptions { artboard: None, object_ids: vectorcraft_svg::ObjectIds::Minimal, ..Default::default() },
))
}
@@ -259,6 +261,26 @@ mod tests {
assert!((b.width() - 30.0).abs() < 1e-6 && (b.height() - 40.0).abs() < 1e-6);
}
#[test]
fn pasted_type_gets_its_layout_bounds_cache() {
// Reported from Illustrator: pasted type kept no layout bounds, so its box (and alignment)
// fell back to the rough estimate until the file was saved and reopened.
let mut s = session();
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="500" height="120"><text x="0" y="80" font-family="Source Sans 3" font-size="40">WWWWWWWW</text></svg>"#;
s.execute("clipboard.importSvg", &json!({"svg": svg})).unwrap();
s.execute("edit.paste", &json!({})).unwrap();
let mut checked = 0;
s.doc().unwrap().doc.walk(|n| {
let vectorcraft_doc::NodeKind::Text(t) = &n.kind else { return };
let cached = t.cached_bounds.expect("pasted type computed its bounds");
let real = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).bounds;
assert!((cached.width() - real.width()).abs() < 1e-6, "{cached:?} {real:?}");
assert!(cached.width() > t.estimate_bounds().width() + 1.0, "{cached:?} is just the estimate");
checked += 1;
});
assert_eq!(checked, 1, "one text object pasted");
}
#[test]
fn paste_never_lands_in_an_object_that_reused_an_undone_layers_id() {
// Found by the model-based test: undo restores the id counter, so a compound path made
+2 -3
View File
@@ -58,7 +58,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Create Text",
[],
None,
"{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on",
"{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on",
has_doc,
text_create
),
@@ -447,8 +447,7 @@ fn text_create(s: &mut Session, p: &Value) -> Result<Value> {
/// The paragraph attributes new type gets (the Type tools, `text.create`): aligned to the start of
/// each paragraph's direction (left, right for Hebrew and Arabic), Japanese punctuation composed
/// with Line-end Punctuation Half Width and Standard burasagari (as in Illustrator). Imported text
/// keeps its own.
/// with Line-end Punctuation Half Width and Standard burasagari. Imported text keeps its own.
pub(crate) fn new_type_para() -> vectorcraft_doc::ParaStyle {
vectorcraft_doc::ParaStyle {
justify: vectorcraft_doc::Justify::Auto,
+19 -4
View File
@@ -55,7 +55,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Paste in Front",
["Edit"],
Some("Cmd+F"),
"{swatchConflict?} paste in place just above the top selected object, or on top of the current layer when nothing is selected (resources and layers as edit.paste) → {ids, added, merged, renamed}",
"{swatchConflict?, artboard?: index (the active artboard: the objects go where they were on the artboard they were copied from, on this one)} paste in place just above the top selected object, or on top of the current layer when nothing is selected (resources and layers as edit.paste) → {ids, added, merged, renamed}",
has_clipboard,
|s, p| paste(s, p, PasteMode::Front)
),
@@ -64,7 +64,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Paste in Back",
["Edit"],
Some("Cmd+B"),
"{swatchConflict?} paste in place just below the bottom selected object, or at the bottom of the current layer when nothing is selected (resources and layers as edit.paste) → {ids, added, merged, renamed}",
"{swatchConflict?, artboard?: index (as edit.pasteInFront)} paste in place just below the bottom selected object, or at the bottom of the current layer when nothing is selected (resources and layers as edit.paste) → {ids, added, merged, renamed}",
has_clipboard,
|s, p| paste(s, p, PasteMode::Back)
),
@@ -73,7 +73,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Paste in Place",
["Edit"],
Some("Cmd+Shift+V"),
"{swatchConflict?} paste where the objects were copied (resources and layers as edit.paste) → {ids, added, merged, renamed}",
"{swatchConflict?, artboard?: index (as edit.pasteInFront)} paste where the objects were copied (resources and layers as edit.paste) → {ids, added, merged, renamed}",
has_clipboard,
|s, p| paste(s, p, PasteMode::InPlace)
),
@@ -310,6 +310,17 @@ fn paste(s: &mut Session, p: &Value, mode: PasteMode) -> Result<Value> {
r
}
/// 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.
fn onto_artboard(doc: &Document, p: &Value, clip: &Clipboard) -> Affine {
let target = p.get("artboard").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()).and_then(|i| doc.artboards.get(i));
match (target, clip.source_artboard) {
(Some(t), Some(src)) => Affine::translate(t.rect.origin() - src.origin()),
_ => Affine::IDENTITY,
}
}
fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, choices: &SwatchChoices) -> Result<Value> {
let off = s.prefs.paste_offset;
let st = s.doc()?;
@@ -345,7 +356,8 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
let src = clip.source_artboard.or_else(|| st.doc.artboards.first().map(|a| a.rect)).map(|r| r.origin()).unwrap_or_default();
st.doc.artboards.iter().map(|a| Affine::translate(a.rect.origin() - src)).collect()
}
_ => vec![Affine::IDENTITY],
// In place, in front, in back: onto the active artboard when the UI names it.
_ => vec![onto_artboard(&st.doc, p, clip)],
};
let (ids, imported, artboard) = s.edit(mode.label(), |d, sel| {
let imported = clip.import_into(d, choices, same_doc);
@@ -381,6 +393,9 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
}
}
sel.set(new_ids.iter().copied());
// Pasted type arrives without the layout bounds cache: fill it so boxes and alignment are
// right away (opening the file later would, but paste must not depend on a reopen).
super::typecmd::refresh_bounds_of(d, &new_ids);
Ok((new_ids, imported, artboard))
})?;
// The Artboard tool takes the pasted artboard.
+509
View File
@@ -0,0 +1,509 @@
//! Affinity documents (`.af`, `.afdesign`, `.afphoto`, `.afpub`): the native layers, curves,
//! shapes, artboards, text and images that `vectorcraft-affinity` reads, mapped to editable
//! VectorCraft art. Whatever the reader could not map is listed in the warnings. A file whose
//! native document can't be read opens as its embedded preview instead, with a warning that says
//! why, and can't be placed.
use std::io::Cursor;
use std::sync::Arc;
use kurbo::{Affine, BezPath, Point, Rect, Vec2};
use vectorcraft_affinity::model::{self as af, Align as AfAlign, Blend, Kind, Pixels};
use vectorcraft_affinity::paint::{self as ap, Cap, GradientKind as AfKind, Join};
use vectorcraft_color::{BlendMode, Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint};
use vectorcraft_doc::appearance::{Appearance, AppearanceItem, Dash, FillLayer, LineCap, LineJoin, StrokeAlign, StrokeLayer};
use vectorcraft_doc::geom::{FillRule, PathData, shapes};
use vectorcraft_doc::text::{CharStyle, Justify, TextKind, TextObject, TextRun};
use vectorcraft_doc::{Artboard, Document, ImageObject, LayerColor, Node, NodeKind, OpacityMask};
use super::load::{err, raster_doc};
use crate::Result;
/// What an Affinity file opened as.
pub(super) struct Imported {
pub doc: Document,
pub warnings: Vec<String>,
/// Only the embedded preview: never placed, used as a template or mined for styles.
pub preview_only: bool,
}
pub(super) fn import(name: &str, bytes: &[u8]) -> Result<Imported> {
match vectorcraft_affinity::read(bytes, vectorcraft_affinity::Limits::default()) {
Ok(d) => {
let mut b = Builder { doc: Document::new(0.0, 0.0), warnings: Vec::new(), k: 72.0 / d.dpi, layer: 0 };
b.document(&d)?;
let mut warnings = d.warnings.iter().map(|w| format!("Affinity: not imported or approximated: {w}.")).collect::<Vec<_>>();
warnings.extend(b.warnings.iter().map(|w| format!("Affinity: {w}.")));
Ok(Imported { doc: b.doc, warnings, preview_only: false })
}
Err(native) => {
let (doc, w) = preview(name, bytes, &native)?;
Ok(Imported { doc, warnings: vec![w], preview_only: true })
}
}
}
/// The embedded preview, for files whose native document can't be read.
fn preview(name: &str, bytes: &[u8], native: &vectorcraft_affinity::Error) -> Result<(Document, String)> {
let p = vectorcraft_affinity::preview(bytes).map_err(|e| err(format!("{native}; its embedded preview can't be read either ({e})")))?;
// `raster_doc` normally probes PNG dimensions and keeps its encoded bytes. Verify this
// untrusted preview's complete pixels first, so corrupt data cannot create a blank image.
let mut reader = image::ImageReader::with_format(Cursor::new(p.png), image::ImageFormat::Png);
let mut limits = image::Limits::default();
limits.max_image_width = Some(4096);
limits.max_image_height = Some(4096);
limits.max_alloc = Some(128 << 20);
reader.limits(limits);
let _pixels = reader.decode().map_err(err)?;
let d = raster_doc(name, p.png)?;
Ok((
d,
format!(
"The native Affinity document could not be read ({native}). Opened only its embedded {}×{} PNG preview, which may be smaller than the document: no layers, vectors, text or pages. Save a new copy; the Affinity source cannot be saved back. Export SVG or PDF from Affinity for editable vectors.",
p.width, p.height
),
))
}
struct Builder {
doc: Document,
warnings: Vec<String>,
/// Points per document pixel.
k: f64,
/// Layers made so far (their colours cycle).
layer: u8,
}
fn rect_of(r: af::Rect) -> Rect {
Rect::new(r.x0, r.y0, r.x1, r.y1)
}
fn affine(a: af::Affine) -> Affine {
Affine::new(a.0)
}
impl Builder {
fn warn(&mut self, w: &str) {
if !self.warnings.iter().any(|x| x == w) {
self.warnings.push(w.to_string());
}
}
fn document(&mut self, d: &af::Document) -> Result<()> {
// Spreads (Publisher pages) sit side by side, 36 pt apart; a single spread keeps its origin.
let mut layers = Vec::new();
let mut artboards = Vec::new();
let mut x = 0.0;
for (i, spread) in d.spreads.iter().enumerate() {
let shift = if i == 0 { 0.0 } else { x - spread.bounds.x0 * self.k };
let to_doc = Affine::translate((shift, 0.0)) * Affine::scale(self.k);
let before = artboards.len();
let mut loose: Vec<Arc<Node>> = Vec::new();
for n in &spread.nodes {
let top = match &n.kind {
Kind::Layer => self.node(n, to_doc, &mut artboards, true),
Kind::Artboard { .. } => self.node(n, to_doc, &mut artboards, true).map(|art| self.layer_of(&n.name, art, n.visible, n.locked)),
_ => {
if let Some(art) = self.node(n, to_doc, &mut artboards, false) {
loose.push(art);
}
continue;
}
};
if !loose.is_empty() {
layers.push(self.layer_with(None, std::mem::take(&mut loose)));
}
layers.extend(top);
}
if !loose.is_empty() {
layers.push(self.layer_with(None, loose));
}
// Without artboards, each page (or the spread) becomes one.
if artboards.len() == before {
let rects = if spread.pages.is_empty() { vec![spread.bounds] } else { spread.pages.clone() };
for r in rects {
let name = format!("{} {}", if d.spreads.len() > 1 || spread.pages.len() > 1 { "Page" } else { "Artboard" }, artboards.len() + 1);
artboards.push((name, to_doc.transform_rect_bbox(rect_of(r))));
}
}
x = artboards.iter().map(|(_, r)| r.x1).fold(x, f64::max) + 36.0;
if spread.transparent && i == 0 {
self.warn("transparent page backgrounds show as white artboards");
}
}
self.doc.artboards = artboards
.into_iter()
.enumerate()
.filter(|(_, (_, r))| r.width() > 0.0 && r.height() > 0.0)
.map(|(i, (name, rect))| Artboard { id: i as u32 + 1, name, rect, show_center_mark: false, show_cross_hairs: false })
.collect();
if self.doc.artboards.is_empty() {
return Err(err("the Affinity document has no page with a size"));
}
// Bottom to top, as Affinity lists them.
self.doc.layers = layers;
if self.doc.layers.is_empty() {
let empty = self.layer_with(None, Vec::new());
self.doc.layers.push(empty);
}
Ok(())
}
fn layer_with(&mut self, name: Option<&str>, children: Vec<Arc<Node>>) -> Arc<Node> {
let id = self.doc.alloc_id();
self.layer = self.layer.wrapping_add(1);
let label = name.map(str::to_string).unwrap_or_else(|| format!("Layer {}", self.layer));
let mut n = Node::layer(id, &label, LayerColor::Preset(self.layer % 9));
if let Some(c) = n.children_mut() {
*c = children;
}
Arc::new(n)
}
/// An artboard at the top level becomes a layer of its name holding its clip group.
fn layer_of(&mut self, name: &str, art: Arc<Node>, visible: bool, locked: bool) -> Arc<Node> {
let mut l = (*self.layer_with(Some(if name.is_empty() { "Artboard" } else { name }), vec![art])).clone();
l.visible = visible;
l.locked = locked;
Arc::new(l)
}
fn base(&mut self, n: &af::Node, kind: NodeKind) -> Node {
let mut out = Node::new(self.doc.alloc_id(), kind);
out.name = (!n.name.is_empty()).then(|| n.name.clone());
out.visible = n.visible;
out.locked = n.locked;
out.opacity = n.opacity as f32;
out.blend = self.blend(n.blend);
out
}
fn blend(&mut self, b: Blend) -> BlendMode {
match b {
Blend::Normal | Blend::PassThrough => BlendMode::Normal,
Blend::Darken => BlendMode::Darken,
Blend::Multiply => BlendMode::Multiply,
Blend::ColorBurn => BlendMode::ColorBurn,
Blend::Lighten => BlendMode::Lighten,
Blend::Screen => BlendMode::Screen,
Blend::ColorDodge => BlendMode::ColorDodge,
Blend::Overlay => BlendMode::Overlay,
Blend::SoftLight => BlendMode::SoftLight,
Blend::HardLight => BlendMode::HardLight,
Blend::Difference => BlendMode::Difference,
Blend::Exclusion => BlendMode::Exclusion,
Blend::Hue => BlendMode::Hue,
Blend::Saturation => BlendMode::Saturation,
Blend::Color => BlendMode::Color,
Blend::Luminosity => BlendMode::Luminosity,
_ => {
self.warn("blend modes VectorCraft doesn't have (Add, Linear Light, Vivid Light, Pin Light, Hard Mix, Subtract, Darker/Lighter Colour, Average, Negation, Reflect, Glow, Erase) import as Normal");
BlendMode::Normal
}
}
}
/// One Affinity node (and its subtree) as VectorCraft art; `to_doc` maps document pixels to
/// points. `layer_level`: the node is directly in a spread or a layer, so a layer stays a layer.
fn node(&mut self, n: &af::Node, to_doc: Affine, boards: &mut Vec<(String, Rect)>, layer_level: bool) -> Option<Arc<Node>> {
let child_level = matches!(n.kind, Kind::Layer) && layer_level;
let children: Vec<Arc<Node>> = n.children.iter().filter_map(|c| self.node(c, to_doc, boards, child_level)).collect();
let mut out = match &n.kind {
Kind::Layer if layer_level => {
let mut l = (*self.layer_with(Some(if n.name.is_empty() { "Layer" } else { &n.name }), children)).clone();
l.visible = n.visible;
l.locked = n.locked;
l.opacity = n.opacity as f32;
l.blend = self.blend(n.blend);
l
}
Kind::Layer | Kind::Group => {
let mut g = self.base(n, NodeKind::Group { children, clip: false });
// Affinity groups isolate their blending unless they pass through.
g.isolate = n.blend != Blend::PassThrough;
g
}
Kind::Shape { path, fills, strokes, even_odd } => {
let data = self.path(path, to_doc);
let rule = if *even_odd { FillRule::EvenOdd } else { FillRule::NonZero };
let appearance = self.appearance(fills, strokes, to_doc);
if children.is_empty() {
let mut p = self.base(n, NodeKind::Path { path: data, rule, live: None, clipping: false, guide: false });
p.appearance = appearance;
p
} else {
// A shape clips its children: VectorCraft's clipping path keeps its paint, its
// fill behind the clipped art and its stroke over it, as Affinity draws it.
let mut clip = Node::new(self.doc.alloc_id(), NodeKind::Path { path: data, rule, live: None, clipping: true, guide: false });
clip.appearance = appearance;
let mut kids = vec![Arc::new(clip)];
kids.extend(children);
self.base(n, NodeKind::Group { children: kids, clip: true })
}
}
Kind::Artboard { rect, background } => {
let r = to_doc.transform_rect_bbox(rect_of(*rect));
boards.push((if n.name.is_empty() { format!("Artboard {}", boards.len() + 1) } else { n.name.clone() }, r));
let mut clip = Node::new(
self.doc.alloc_id(),
NodeKind::Path { path: shapes::rectangle(r), rule: FillRule::NonZero, live: None, clipping: true, guide: false },
);
clip.appearance = self.appearance(background, &[], to_doc);
let mut kids = vec![Arc::new(clip)];
kids.extend(children);
let mut g = self.base(n, NodeKind::Group { children: kids, clip: true });
g.name = Some(if n.name.is_empty() { "Artboard".into() } else { n.name.clone() });
g
}
Kind::Text(t) => {
let text = self.text(t, to_doc);
let mut node = self.base(n, NodeKind::Text(Box::new(text)));
if !children.is_empty() {
let mut kids = vec![Arc::new(node)];
kids.extend(children);
node = Node::group(self.doc.alloc_id(), kids);
}
node
}
Kind::Image(img) => match self.image(img, to_doc) {
Some(kind) => self.base(n, kind),
None if children.is_empty() => return None,
None => self.base(n, NodeKind::Group { children, clip: false }),
},
Kind::Unsupported if children.is_empty() => return None,
Kind::Unsupported => self.base(n, NodeKind::Group { children, clip: false }),
};
if let Some(img) = &n.pixel_mask {
// A pixel mask: its grey levels show (white) or hide (black) the node, and nothing
// shows outside it, as an opacity mask that clips.
if let Some(kind) = self.image(img, to_doc) {
let art = Node::new(self.doc.alloc_id(), kind);
out.mask = Some(Box::new(OpacityMask::new(art, true)));
}
}
if let Some(mask) = &n.mask {
// A vector mask: the node shows only inside it.
let clip = Node::new(
self.doc.alloc_id(),
NodeKind::Path { path: self.path(mask, to_doc), rule: FillRule::NonZero, live: None, clipping: true, guide: false },
);
let (opacity, blend, visible, name) = (out.opacity, out.blend, out.visible, out.name.clone());
out.opacity = 1.0;
out.blend = BlendMode::Normal;
let mut g = Node::new(self.doc.alloc_id(), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(out)], clip: true });
(g.opacity, g.blend, g.visible, g.name) = (opacity, blend, visible, name);
out = g;
}
Some(Arc::new(out))
}
fn path(&self, p: &af::Path, to_doc: Affine) -> PathData {
let mut bp = BezPath::new();
for s in &p.subpaths {
let pt = |q: af::Point| Point::new(q.x, q.y);
bp.move_to(pt(s.start));
for [c1, c2, e] in &s.segments {
if c1 == c2 && c2 == e {
bp.line_to(pt(*e));
} else {
bp.curve_to(pt(*c1), pt(*c2), pt(*e));
}
}
if s.closed {
bp.close_path();
}
}
PathData::from_bezpath(&(to_doc * bp))
}
fn color(c: ap::Color) -> (Color, f32) {
let f = |v: f64| v.clamp(0.0, 1.0) as f32;
match c {
ap::Color::Rgb { r, g, b, a } => (Color::Rgb { r: f(r), g: f(g), b: f(b) }, f(a)),
ap::Color::Cmyk { c, m, y, k, a } => (Color::Cmyk { c: f(c), m: f(m), y: f(y), k: f(k) }, f(a)),
ap::Color::Gray { v, a } => (Color::Gray { k: 1.0 - f(v) }, f(a)),
ap::Color::Lab { l, a, b, alpha } => (Color::Lab { l: l as f32, a: a as f32, b: b as f32 }, f(alpha)),
}
}
/// A paint and the opacity its colour's alpha becomes.
fn paint(&mut self, p: &ap::Paint, to_doc: Affine) -> Option<(Paint, f32)> {
match p {
ap::Paint::None => None,
ap::Paint::Solid(c) => {
let (color, a) = Self::color(*c);
Some((Paint::solid(color), a))
}
ap::Paint::Gradient(g) => {
let stops = g
.stops
.iter()
.map(|s| {
let (color, opacity) = Self::color(s.color);
let mut stop = GradientStop::new(s.offset as f32, color);
stop.opacity = opacity;
stop.midpoint = s.half_point().clamp(0.13, 0.87) as f32;
stop
})
.collect();
let m = to_doc * affine(g.transform);
let (o, x, y) = (m * Point::ZERO, m * Point::new(1.0, 0.0), m * Point::new(0.0, 1.0));
let kind = match g.kind {
AfKind::Linear => GradientKind::Linear,
AfKind::Radial => GradientKind::Radial,
AfKind::Conical => {
self.warn("conical gradients import as radial gradients");
GradientKind::Radial
}
};
let (u, v): (Vec2, Vec2) = (x - o, y - o);
if kind == GradientKind::Radial && u.hypot() > 0.0 && (u.dot(v) / (u.hypot() * v.hypot().max(1e-12))).abs() > 0.01 {
self.warn("skewed elliptical gradients import as upright ellipses");
}
let aspect = if u.hypot() > 0.0 { (v.hypot() / u.hypot()).clamp(0.01, 100.0) } else { 1.0 };
let geom = GradientGeom { start: o, end: x, aspect: if kind == GradientKind::Radial { aspect } else { 1.0 }, focal: None };
let mut gp = GradientPaint::new(Gradient { kind, stops });
gp.angle = (-(u.y)).atan2(u.x).to_degrees();
gp.geom = Some(geom);
Some((Paint::Gradient(Box::new(gp)), 1.0))
}
}
}
fn appearance(&mut self, fills: &[ap::Paint], strokes: &[ap::Stroke], to_doc: Affine) -> Appearance {
let mut items = Vec::new();
let mut above = Vec::new();
for f in fills {
if let Some((paint, opacity)) = self.paint(f, to_doc) {
let mut l = FillLayer::new(paint);
l.opacity = opacity;
items.push(AppearanceItem::Fill(l));
}
}
for s in strokes {
let Some((paint, opacity)) = self.paint(&s.paint, to_doc) else { continue };
let mut l = StrokeLayer::new(paint, s.width * self.k);
l.opacity = opacity;
l.cap = match s.cap {
Cap::Butt => LineCap::Butt,
Cap::Round => LineCap::Round,
Cap::Square => LineCap::Square,
};
l.join = match s.join {
Join::Miter => LineJoin::Miter,
Join::Round => LineJoin::Round,
Join::Bevel => LineJoin::Bevel,
};
l.miter_limit = s.miter_limit;
l.align = match s.align {
ap::Align::Center => StrokeAlign::Center,
ap::Align::Inside => StrokeAlign::Inside,
ap::Align::Outside => StrokeAlign::Outside,
};
l.dash = s.dash.as_ref().map(|(pattern, phase)| Dash {
pattern: pattern.iter().map(|v| v * self.k).collect(),
offset: phase * self.k,
align_corners: false,
});
if s.behind {
items.insert(0, AppearanceItem::Stroke(l));
} else {
above.push(AppearanceItem::Stroke(l));
}
}
items.extend(above);
Appearance { items, ..Appearance::default() }
}
fn text(&mut self, t: &af::Text, to_doc: Affine) -> TextObject {
let origin = match t.frame {
Some(f) => Point::new(f.x0, f.y0),
None => Point::new(t.anchor.x, t.anchor.y),
};
// Text space: origin at the anchor (or frame corner), the transform's uniform scale moved
// into the type size so sizes read in points.
let m = to_doc * affine(t.transform) * Affine::translate(origin.to_vec2());
let c = m.as_coeffs();
let k = (c[0] * c[3] - c[1] * c[2]).abs().sqrt().max(1e-9);
let xf = m * Affine::scale(1.0 / k);
let mut obj = TextObject::point(Point::ZERO, "", CharStyle::default());
obj.xf = xf;
obj.runs = t
.runs
.iter()
.map(|r| {
let mut style =
CharStyle { font_family: if r.family.is_empty() { r.postscript.clone() } else { r.family.clone() }, ..CharStyle::default() };
style.font_style = font_style(r.weight, r.italic);
style.size = r.size * k;
style.leading = r.leading.map(|l| l * k);
style.tracking = r.tracking * 1000.0;
style.fill = match self.paint(&r.fill, to_doc) {
Some((p, a)) => {
if a < 0.999 {
self.warn("translucent text colours import opaque");
}
p
}
None => Paint::None,
};
// Affinity separates paragraphs with U+2029 and lines with U+2028.
TextRun::new(r.text.replace(['\u{2029}', '\u{2028}'], "\n"), style)
})
.collect();
obj.para.justify = match t.align {
AfAlign::Left => Justify::Left,
AfAlign::Center => Justify::Center,
AfAlign::Right => Justify::Right,
AfAlign::Justify => Justify::JustifyLeft,
};
if let Some(f) = t.frame {
obj.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, (f.x1 - f.x0) * k, (f.y1 - f.y0) * k)) };
}
obj
}
fn image(&mut self, img: &af::Image, to_doc: Affine) -> Option<NodeKind> {
let bytes = match &img.pixels {
Pixels::Encoded(b) => b.clone(),
Pixels::Rgba8(rgba) => {
let buf = image::RgbaImage::from_raw(img.width, img.height, rgba.clone())?;
let mut png = Vec::new();
buf.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).ok()?;
png
}
};
let r = match super::load::raster_image(&bytes) {
Ok(r) => r,
Err(_) => {
self.warn("an embedded image that could not be decoded was left out");
return None;
}
};
// The pixel grid maps onto the Affinity image's own size, whatever the stored file's.
let sx = img.width as f64 / f64::from(r.width.max(1));
let sy = img.height as f64 / f64::from(r.height.max(1));
let xf = to_doc * affine(img.transform) * Affine::scale_non_uniform(sx, sy);
self.doc.images.entry(r.key.clone()).or_insert(r.blob);
Some(NodeKind::Image(ImageObject { key: r.key, width: r.width, height: r.height, xf, link: None, placement: Default::default() }))
}
}
fn font_style(weight: i64, italic: bool) -> String {
let w = match weight {
..=150 => "Thin",
151..=250 => "ExtraLight",
251..=325 => "Light",
326..=450 => "Regular",
451..=550 => "Medium",
551..=650 => "SemiBold",
651..=750 => "Bold",
751..=850 => "ExtraBold",
_ => "Black",
};
match (w, italic) {
("Regular", true) => "Italic".into(),
(w, true) => format!("{w} Italic"),
(w, false) => w.into(),
}
}
+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
)]
+48 -14
View File
@@ -21,6 +21,9 @@ pub struct Loaded {
/// An older native file (the former `.drawcraft` name or format version): saving it again
/// rewrites it in today's format.
pub converted: bool,
/// Only a stand-in picture of the file (an Affinity document whose native data couldn't be
/// read): Open shows it with its warning; Place, templates and libraries refuse it.
pub preview_only: bool,
}
/// An image ready to embed: PNG/JPEG/GIF/WebP keep their bytes, other formats are stored as PNG
@@ -48,6 +51,9 @@ pub fn file_name(name: &str) -> String {
/// extension of `name`.
pub fn detect(name: &str, bytes: &[u8]) -> Option<&'static Format> {
let by_name = format_for_name(name).filter(|f| f.read);
if vectorcraft_affinity::is_affinity(bytes) {
return format("affinity");
}
if vectorcraft_format::sniff(bytes) {
// A template keeps its meaning from the extension, like .ait.
return by_name.filter(|f| f.id == "template").or_else(|| format("vectorcraft"));
@@ -135,6 +141,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
})?;
let title = file_name(name);
let mut converted = false;
let mut preview_only = false;
let (mut doc, warnings, restored) = match format.id {
// EPS and PostScript .ai: the document our EPS files carry, else the PostScript read.
_ if format.id == "eps" || vectorcraft_pdf::is_postscript(bytes) => {
@@ -174,6 +181,11 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
let (doc, warnings) = super::metafile::import(bytes)?;
(doc, warnings, false)
}
"affinity" => {
let i = super::affinity::import(&title, bytes)?;
preview_only = i.preview_only;
(i.doc, i.warnings, false)
}
_ if format.raster => (raster_doc(&title, bytes)?, vec![], false),
_ => return Err(err(format!("{} files can't be opened yet", format.label))),
};
@@ -185,7 +197,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
if !matches!(format.id, "vectorcraft" | "template") || doc.title.is_empty() {
doc.title = title;
}
Ok(Loaded { doc, format, warnings, restored, converted })
Ok(Loaded { doc, format, warnings, restored, converted, preview_only })
}
/// Import SVG text, reading the files its images link to (relative links from `folder`, the SVG's
@@ -220,12 +232,18 @@ pub fn open_bytes_with(s: &mut Session, name: &str, bytes: &[u8], path: Option<S
/// File → New from Template: any readable file (at `path`, if it is one: its links are looked for
/// from there) as a new untitled document.
pub fn open_template(s: &mut Session, name: &str, bytes: &[u8], path: Option<&str>) -> Result<Value> {
open_loaded(s, load(name, bytes)?, path.map(str::to_string), &LoadOptions::default(), true)
let loaded = load(name, bytes)?;
if loaded.preview_only {
return Err(err(
"only this Affinity file's embedded preview could be read: use File › Open to see it with its warning, or export SVG or PDF from Affinity first",
));
}
open_loaded(s, loaded, path.map(str::to_string), &LoadOptions::default(), true)
}
/// 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;
let Loaded { mut doc, format, warnings, restored, converted, .. } = loaded;
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.
@@ -282,10 +300,19 @@ pub(super) fn new_from_template(s: &mut Session, p: &Value) -> Result<Value> {
open_template(s, src.name, &src.bytes, src.path)
}
/// The most memory decoding one image may take: a 60 in print sheet at 300 ppi (18000 × 6600 px)
/// is ~475 MB as RGBA, past the decoder's 512 MB default once it is converted. The web app keeps
/// that default: wasm32 has 4 GB to address and aborts when an allocation fails.
const MAX_RASTER_ALLOC: u64 = if cfg!(target_arch = "wasm32") { 512 << 20 } else { 2 << 30 };
/// Decode an image's header (and, for formats stored as PNG, its pixels). CMYK TIFFs are kept as
/// they are, with their ink amounts ([`ImageBlob::cmyk`]).
/// they are, with their ink amounts ([`ImageBlob::cmyk`]); CMYK TIFFs with an alpha channel, which
/// the decoder can't read, become RGBA in the active colour settings' CMYK.
pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
let reader = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().map_err(err)?;
let mut reader = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().map_err(err)?;
let mut limits = image::Limits::default();
limits.max_alloc = Some(MAX_RASTER_ALLOC);
reader.limits(limits);
let kind = reader.format().ok_or_else(|| err("not an image VectorCraft reads (see document.formats)"))?;
let f = image_format(kind).ok_or_else(|| err(format!("{kind:?} images can't be opened (see document.formats)")))?;
let ppi = super::ppi::resolution(bytes);
@@ -293,7 +320,14 @@ pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
let (bytes, mime, (width, height)) = if matches!(f.id, "png" | "jpg" | "gif" | "webp") || (f.id == "tiff" && cmyk()) {
(bytes.to_vec(), f.mime, reader.into_dimensions().map_err(err)?)
} else {
let img = reader.decode().map_err(err)?.to_rgba8();
let cmyka = || {
let cms = vectorcraft_color::cms::active();
vectorcraft_doc::cmyk::cmyka_tiff_rgba(bytes, |c| cms.cmyk_to_srgb(c, false))
};
let img = match (f.id == "tiff").then(cmyka).flatten() {
Some(img) => img,
None => reader.decode().map_err(err)?.to_rgba8(),
};
let size = img.dimensions();
let mut png = Vec::new();
img.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).map_err(err)?;
@@ -311,16 +345,16 @@ pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
Ok(RasterImage { key: blob.content_key(), blob, width, height, ppi })
}
/// An image as a document of its pixel size (1 px = 1 pt), the image named after the file.
fn raster_doc(name: &str, bytes: &[u8]) -> Result<Document> {
let RasterImage { key, blob, width, height, .. } = raster_image(bytes)?;
let mut d = Document::new(width as f64, height as f64);
/// An image as a document of its physical size at the resolution it declares (as Place sizes it;
/// 72 ppi, 1 px = 1 pt, when it declares none), the image named after the file.
pub(super) fn raster_doc(name: &str, bytes: &[u8]) -> Result<Document> {
let RasterImage { key, blob, width, height, ppi } = raster_image(bytes)?;
let (sx, sy) = crate::cmd::place::pt_per_px(ppi);
let mut d = Document::new(width as f64 * sx, height as f64 * sy);
let layer = d.layers[0].id;
let id = d.alloc_id();
let mut n = Node::new(
id,
NodeKind::Image(ImageObject { key: key.clone(), width, height, xf: Affine::IDENTITY, link: None, placement: Default::default() }),
);
let xf = Affine::scale_non_uniform(sx, sy);
let mut n = Node::new(id, NodeKind::Image(ImageObject { key: key.clone(), width, height, xf, link: None, placement: Default::default() }));
n.name = Some(name.to_string());
d.images.insert(key, blob);
d.insert(Some(layer), 0, n).map_err(err)?;
+18 -4
View File
@@ -19,6 +19,7 @@
//! [`FORMATS`] is the single list of formats (append-only); open dialogs use [`open_filters`],
//! agents query `document.formats`.
mod affinity;
mod batch;
pub mod dxf;
pub mod dxfimport;
@@ -56,7 +57,7 @@ pub use pdfimport::{LoadOptions, page_document};
pub(crate) use save::job_for;
pub use save::{
SAVE_FORMATS, SaveJob, SaveMode, SavePlan, export_folder, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates,
templates_folder,
templates_dialog_folder, templates_folder,
};
pub use screens::{PRESETS as SCREEN_PRESETS, ScreenSize, preset_rows as screen_preset_rows};
pub(crate) use screens::{
@@ -78,7 +79,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Open Document",
[],
None,
"{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), DXF: dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's <image> files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)",
"{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), DXF: dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's <image> files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)",
always,
load::open
),
@@ -555,6 +556,9 @@ pub const FORMATS: &[Format] = &[
Format { id: "wmf", label: "WMF", extensions: &["wmf"], mime: "image/wmf", read: true, write: true, raster: false, options: metafile::OPTIONS },
Format { id: "tga", label: "Targa", extensions: &["tga"], mime: "image/x-tga", read: false, write: true, raster: true, options: TGA_OPTIONS },
Format { id: "psd", label: "PSD", extensions: &["psd"], mime: "image/x-psd", read: false, write: true, raster: true, options: PSD_OPTIONS },
// Affinity Photo's `.afphoto` opens by its content but isn't listed: Finder shouldn't offer a
// vector app for a raster editor's documents.
reader("affinity", "Affinity", &["af", "afdesign", "afpub"], "application/vnd.affinity", false),
];
/// Every extension `document.open` reads (the "All readable files" filter of open dialogs).
@@ -579,6 +583,9 @@ pub const OPEN_EXTS: &[&str] = &[
"emf",
"wmf",
"eps",
"af",
"afdesign",
"afpub",
];
/// The extension that picks each writable format when exporting (the format's first; PNG-8 shares
@@ -618,6 +625,9 @@ pub const PLACE_EXTS: &[&str] = &[
"emf",
"wmf",
"eps",
"af",
"afdesign",
"afpub",
"txt",
];
@@ -638,9 +648,11 @@ pub fn open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static
.chain(std::iter::once(("Plug-ins", super::plugin::EXTS)))
}
/// File → Place dialog filters: "All placeable files", then one per readable format, then text.
/// File → Place dialog filters: "All placeable files", then one per placeable format, then text.
pub fn place_filters() -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
std::iter::once(("All placeable files", PLACE_EXTS)).chain(format_filters()).chain(std::iter::once(("Text", TEXT_EXTS)))
std::iter::once(("All placeable files", PLACE_EXTS))
.chain(format_filters().filter(|(_, exts)| exts.iter().all(|ext| PLACE_EXTS.contains(ext))))
.chain(std::iter::once(("Text", TEXT_EXTS)))
}
/// A format by id or extension (any case, leading dot allowed; `jpeg` finds `jpg`).
@@ -860,6 +872,8 @@ pub(crate) fn write_or_return(path: Option<&str>, bytes: &[u8], extra: Value) ->
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_affinity;
#[cfg(test)]
mod tests_pdf;
#[cfg(test)]
mod tests_pdfcolor;
+93 -11
View File
@@ -98,13 +98,52 @@ pub fn stamp_save_dates(st: &mut DocState, at: i64) {
d.metadata.modified = Some(at);
}
/// The Templates folder: the `templatesFolder` preference, else `Documents/VectorCraft Templates`
/// in the user's home (none where there is no home folder, as on the web).
/// The Templates folder: the `templatesFolder` preference, else `VectorCraft Templates` in the
/// user's documents folder (none where there is no home folder, as on the web).
pub fn templates_folder(prefs: &Prefs) -> Option<String> {
if !prefs.templates_folder.is_empty() {
return Some(prefs.templates_folder.clone());
}
Some(std::path::Path::new(&home_folder()?).join("Documents").join("VectorCraft Templates").to_string_lossy().to_string())
Some(documents_folder()?.join("VectorCraft Templates").to_string_lossy().to_string())
}
/// The Templates folder for a file dialog to start in (#703): made when it's missing; where it
/// can't be, the nearest folder above it that exists, else the home folder. A dialog asked to
/// start in a folder that isn't there shows an error on some desktops.
pub fn templates_dialog_folder(prefs: &Prefs) -> Option<String> {
let folder = std::path::PathBuf::from(templates_folder(prefs)?);
if !folder.is_dir() {
// Best effort: a folder that can't be made leaves the nearest one that exists.
let _ = std::fs::create_dir_all(&folder);
}
folder.ancestors().find(|a| a.is_dir()).map(|a| a.to_string_lossy().into_owned()).or_else(home_folder)
}
/// The user's documents folder: on Linux and the BSDs the one the desktop names
/// (`XDG_DOCUMENTS_DIR` in `user-dirs.dirs`: `~/Documenti`, `~/Dokumente`…), elsewhere `Documents`
/// in the home folder.
fn documents_folder() -> Option<std::path::PathBuf> {
let home = std::path::PathBuf::from(home_folder()?);
if cfg!(all(unix, not(target_os = "macos"))) {
let config =
std::env::var_os("XDG_CONFIG_HOME").map(std::path::PathBuf::from).filter(|p| p.is_absolute()).unwrap_or_else(|| home.join(".config"));
if let Some(dir) = std::fs::read_to_string(config.join("user-dirs.dirs")).ok().and_then(|t| xdg_documents(&t, &home)) {
return Some(dir);
}
}
Some(home.join("Documents"))
}
/// `XDG_DOCUMENTS_DIR` in a `user-dirs.dirs` file's `text`, `$HOME` being `home`. None when it isn't
/// set, is relative, or is the home folder itself (the spec's way of turning it off).
fn xdg_documents(text: &str, home: &std::path::Path) -> Option<std::path::PathBuf> {
let line = text.lines().map(str::trim).find(|l| l.starts_with("XDG_DOCUMENTS_DIR="))?;
let value = line.split_once('=')?.1.trim().trim_matches('"');
let dir = match value.strip_prefix("$HOME") {
Some(rest) => home.join(rest.trim_start_matches('/')),
None => std::path::PathBuf::from(value),
};
(dir.has_root() && dir != home).then_some(dir)
}
/// The user's home folder (`HOME`, else `USERPROFILE` on Windows; none on the web).
@@ -189,7 +228,7 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result<SavePlan> {
SaveMode::Save | SaveMode::SaveAs => format!("{stem}.{ext}"),
};
let folder = match mode {
SaveMode::Template => templates_folder(&s.prefs),
SaveMode::Template => templates_dialog_folder(&s.prefs),
_ => st.path.as_deref().and_then(parent_folder),
};
let modified = date_param(p, "modified", cmd)?;
@@ -216,15 +255,20 @@ fn is_svg(f: &Format) -> bool {
matches!(f.id, "svg" | "svgz")
}
/// The document as written: native files carry the view to reopen at.
/// The document as written: native files carry the view to reopen at and the Layers panel's open
/// rows.
fn doc_to_save(st: &DocState, f: &Format) -> Arc<Document> {
if is_native(f) && st.doc.last_view != st.view {
let mut d = (*st.doc).clone();
d.last_view = st.view.clone();
Arc::new(d)
} else {
st.doc.clone()
if !is_native(f) {
return st.doc.clone();
}
let open = st.layers_open.saved(&st.doc);
if st.doc.last_view == st.view && st.doc.layers_open == open {
return st.doc.clone();
}
let mut d = (*st.doc).clone();
d.last_view = st.view.clone();
d.layers_open = open;
Arc::new(d)
}
/// What a format loses against a native file (reported whenever a save writes it).
@@ -274,6 +318,7 @@ fn artboard_doc(doc: &Document, i: usize) -> Document {
d.artboards = vec![a.clone()];
// It opens fitted to its artboard, not at the master file's view.
d.last_view = None;
d.layers_open = None;
for layer in &mut d.layers {
keep_on(layer, r);
}
@@ -608,3 +653,40 @@ pub(super) fn specs() -> Vec<CommandSpec> {
),
]
}
#[cfg(test)]
mod tests_folders {
use std::path::{Path, PathBuf};
/// #703: the documents folder a desktop names in `user-dirs.dirs`, not an English `Documents`.
#[test]
fn the_desktops_documents_folder_is_read_from_user_dirs() {
let home = Path::new("/home/david");
let file = "# comment\nXDG_DESKTOP_DIR=\"$HOME/Scrivania\"\nXDG_DOCUMENTS_DIR=\"$HOME/Documenti\"\n";
assert_eq!(super::xdg_documents(file, home), Some(PathBuf::from("/home/david/Documenti")));
assert_eq!(super::xdg_documents("XDG_DOCUMENTS_DIR=\"/data/docs\"", home), Some(PathBuf::from("/data/docs")));
// "$HOME/" turns it off; a relative or missing one isn't used.
assert_eq!(super::xdg_documents("XDG_DOCUMENTS_DIR=\"$HOME/\"", home), None);
assert_eq!(super::xdg_documents("XDG_DOCUMENTS_DIR=\"docs\"", home), None);
assert_eq!(super::xdg_documents("XDG_MUSIC_DIR=\"$HOME/Musica\"", home), None);
}
/// #703: the Templates folder a dialog starts in exists: made when missing, else the nearest
/// folder above it.
#[test]
fn the_templates_dialog_folder_exists() {
let base = std::env::temp_dir().join(format!("vc-templates-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
let mut prefs = crate::Prefs::default();
let wanted = base.join("Modelli").join("VectorCraft Templates");
prefs.templates_folder = wanted.to_string_lossy().into_owned();
assert_eq!(super::templates_dialog_folder(&prefs).map(PathBuf::from), Some(wanted.clone()));
assert!(wanted.is_dir(), "made");
// A file in the way: the nearest folder that exists.
std::fs::write(base.join("blocked"), b"x").unwrap();
prefs.templates_folder = base.join("blocked").join("Templates").to_string_lossy().into_owned();
assert_eq!(super::templates_dialog_folder(&prefs).map(PathBuf::from), Some(base.clone()));
let _ = std::fs::remove_dir_all(&base);
}
}
+3
View File
@@ -153,6 +153,9 @@ pub(super) fn encode(doc: &Document, p: &Value, compressed: bool) -> Result<Enco
let (opts, boards, chosen) = plan(doc, p)?;
// Preserve editing embeds the native document (once, shared by every file).
let native = opts.preserve_editing.then(|| vectorcraft_format::save(doc, false));
// SVG has no filters for the Photoshop-style effects: their objects go in as images.
let flat = crate::cmd::rasterfx::flatten_pixel_effects(doc);
let doc = flat.as_ref().unwrap_or(doc);
let mut enc = Encoded::default();
for artboard in boards {
// A chosen artboard's file holds the art over it (#550).
@@ -0,0 +1,331 @@
//! Affinity documents: native import (layers, artboards, curves, paints) through Open, Place and
//! templates, and the embedded preview only as an explicitly warned fallback.
use super::*;
use proptest::prelude::*;
use serde_json::json;
use std::io::Cursor;
fn png() -> Vec<u8> {
let im = image::RgbaImage::from_fn(2, 1, |x, _| if x == 0 { image::Rgba([220, 40, 60, 255]) } else { image::Rgba([0, 0, 0, 0]) });
let mut bytes = Vec::new();
im.write_to(&mut Cursor::new(&mut bytes), image::ImageFormat::Png).unwrap();
bytes
}
/// Synthetic envelope without a native object graph; not a native Affinity writer.
fn file(png: &[u8]) -> Vec<u8> {
let mut b = vec![0; 72];
b[..4].copy_from_slice(vectorcraft_affinity::MAGIC);
b[4..6].copy_from_slice(&12u16.to_le_bytes());
b[8..12].copy_from_slice(b"nsrP");
b[12..16].copy_from_slice(b"#Inf");
b[24..32].copy_from_slice(&72u64.to_le_bytes());
b[64..68].copy_from_slice(b"Prot");
b.extend(b"\xff\xff\xff\xffThmb");
b.extend(1u32.to_le_bytes());
b.extend((png.len() as u32 + 13).to_le_bytes());
b.extend(29u32.to_le_bytes());
b.extend(0u32.to_le_bytes());
b.extend((png.len() as u32).to_le_bytes());
b.push(1);
b.extend(png);
b
}
#[test]
fn preview_opens_with_a_warning_and_never_saves_to_the_source() {
let mut s = Session::new();
let bytes = file(&png());
for name in ["drawing.af", "renamed.png", "unknown"] {
let result = open_bytes(&mut s, name, &bytes, Some(format!("/source/{name}"))).unwrap();
assert_eq!(result["format"], "affinity");
let w = result["warnings"][0].as_str().unwrap();
assert!(w.contains("2×1") && w.contains("native Affinity document could not be read") && w.contains("no layers"), "{w}");
let st = s.doc().unwrap();
assert!(st.path.is_none());
assert_eq!(st.format, "vectorcraft");
assert_eq!((st.doc.artboards[0].rect.width(), st.doc.artboards[0].rect.height()), (2.0, 1.0));
assert_eq!(st.doc.images.values().next().unwrap().bytes.as_slice(), png());
let saved = vectorcraft_format::save_file(&st.doc);
let reopened = vectorcraft_format::load_file(&saved).unwrap();
assert_eq!(reopened.doc.images, st.doc.images);
let plan = save_plan(&s, SaveMode::Save, &json!({})).unwrap();
assert!(plan.path.is_none(), "Save must ask for a new destination");
assert_eq!(plan.format.id, "vectorcraft");
assert!(plan.name.ends_with(".vectorcraft"));
}
let f = format("affinity").unwrap();
assert!(f.read && !f.write);
for ext in ["af", "afdesign", "afpub"] {
assert!(OPEN_EXTS.contains(&ext) && PLACE_EXTS.contains(&ext), "{ext}");
}
assert!(!SAVE_FORMATS.contains(&"affinity"));
assert!(s.execute("document.serialize", &json!({"format":"af"})).is_err());
assert!(s.execute("document.export", &json!({"format":"affinity"})).is_err());
assert!(save_format(None, Some("drawing.af")).is_err());
}
#[test]
fn preview_reaches_the_document_open_command() {
let mut s = Session::new();
let r = s.execute("document.open", &json!({"name":"test.af", "dataBase64":vectorcraft_format::base64_encode(&file(&png()))})).unwrap();
assert_eq!(r["format"], "affinity");
assert_eq!(r["warnings"].as_array().unwrap().len(), 1);
}
#[test]
fn placing_a_preview_is_rejected_even_when_renamed_or_queued() {
let mut s = Session::new();
s.execute("file.new", &json!({"width":100,"height":100})).unwrap();
let original = s.doc().unwrap().doc.clone();
for name in ["test.af", "renamed.png", "renamed.txt"] {
let p = json!({"name":name, "dataBase64":vectorcraft_format::base64_encode(&file(&png())), "link":true});
for cmd in ["file.place", "file.place.info"] {
let e = s.execute(cmd, &p).unwrap_err().to_string();
assert!(e.contains("File › Open") && e.contains("embedded preview"), "{e}");
}
let e = s.execute("file.place.queue", &json!({"files":[p]})).unwrap_err().to_string();
assert!(e.contains("File › Open") && e.contains("embedded preview"), "{e}");
}
assert_eq!(s.doc().unwrap().doc, original);
}
#[test]
fn templates_and_libraries_cannot_silently_extract_preview_content() {
let mut s = Session::new();
s.execute("file.new", &json!({"width":100,"height":100})).unwrap();
let original = s.doc().unwrap().doc.clone();
for name in ["test.af", "renamed.svg"] {
let p = json!({"name":name, "dataBase64":vectorcraft_format::base64_encode(&file(&png()))});
for cmd in ["file.newFromTemplate", "swatch.library.load", "graphicStyle.loadLibrary"] {
let e = s.execute(cmd, &p).unwrap_err().to_string();
assert!(e.contains("File › Open"), "{cmd}: {e}");
}
}
assert_eq!(s.documents().len(), 1);
assert_eq!(s.doc().unwrap().doc, original);
}
#[test]
fn malformed_crc_and_every_truncation_fail() {
let b = file(&png());
for end in 0..b.len() {
assert!(load("bad.af", &b[..end]).is_err(), "{end}");
}
let mut crc = b.clone();
crc[130] ^= 1;
assert!(load("bad.af", &crc).is_err());
// Affinity 1 and 2 (container versions 8 to 11) store the same preview record.
let mut legacy = b.clone();
legacy[4..6].copy_from_slice(&10u16.to_le_bytes());
assert!(load("old.afdesign", &legacy).is_ok());
let mut unknown = b.clone();
unknown[4..6].copy_from_slice(&13u16.to_le_bytes());
assert!(load("new.af", &unknown).err().unwrap().to_string().contains("unknown container version"));
}
#[test]
fn end_and_post_image_chunk_crcs_cannot_escape_pixel_decoding() {
let mut end_crc = png();
*end_crc.last_mut().unwrap() ^= 1;
assert!(load("bad-end.af", &file(&end_crc)).is_err());
let body = b"Note\0Synthetic";
let mut text = (body.len() as u32).to_be_bytes().to_vec();
text.extend(b"tEXt");
text.extend(body);
let mut h = crc32fast::Hasher::new();
h.update(b"tEXt");
h.update(body);
text.extend(h.finalize().to_be_bytes());
let mut post_image = png();
let at = post_image.len() - 12;
post_image.splice(at..at, text);
assert!(load("valid-text.af", &file(&post_image)).is_ok());
post_image[at + 8 + body.len()] ^= 1;
assert!(load("bad-text.af", &file(&post_image)).is_err());
}
#[test]
fn a_previewless_file_fails_without_replacing_the_active_document() {
let mut s = Session::new();
s.execute("file.new", &json!({"width":100,"height":100})).unwrap();
let original = s.doc().unwrap().doc.clone();
let mut bytes = file(&png());
bytes[24..32].copy_from_slice(&0u64.to_le_bytes());
let e = s.execute("document.open", &json!({"name":"no-preview.af", "dataBase64":vectorcraft_format::base64_encode(&bytes)})).unwrap_err();
assert!(e.to_string().contains("no embedded preview"));
assert_eq!(s.documents().len(), 1);
assert_eq!(s.doc().unwrap().doc, original);
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(128))]
#[test]
fn bounded_mutations_do_not_panic(edits in prop::collection::vec((0usize..300, any::<u8>()), 0..16)) {
let mut b = file(&png());
for (i, value) in edits { if let Some(byte) = b.get_mut(i) { *byte = value; } }
let _ = load("mutated.af", &b);
}
}
mod native {
use super::*;
use vectorcraft_affinity::synth::{self, F, Method, tag};
use vectorcraft_doc::{NodeKind, appearance::AppearanceItem};
fn rgba(r: f32, g: f32, b: f32, a: f32) -> F {
F::Struct([r, g, b, a].iter().flat_map(|v| v.to_le_bytes()).collect())
}
fn solid(id: u32, c: F) -> F {
F::Def(
id,
vec![tag(b"FDsc")],
vec![(
tag(b"FDeF"),
F::Def(id + 1, vec![tag(b"FilS")], vec![(tag(b"Colr"), F::Def(id + 2, vec![tag(b"RGBA")], vec![(tag(b"_col"), c)]))]),
)],
)
}
fn node(x: f64, y: f64, kind: u8, role: u8) -> Vec<u8> {
let mut r = x.to_le_bytes().to_vec();
r.extend(y.to_le_bytes());
r.extend([kind, role]);
r
}
/// A version-12 document at 144 ppi: an artboard "Board" (moved 20, 10) holding a closed
/// triangle with a red fill and a 4 px blue stroke, and a hidden ellipse on a layer.
pub(super) fn document(method: Method) -> Vec<u8> {
let triangle = F::Def(
20,
vec![tag(b"PCrv"), tag(b"VNod"), tag(b"Node")],
vec![
(tag(b"Desc"), F::Str("Triangle".into())),
(
tag(b"Crvs"),
F::Obj(
tag(b"PCvD"),
vec![(
tag(b"Data"),
F::Pos(vec![
F::U8(0),
F::U32(1),
F::Bool(true),
F::Records(18, vec![node(0.0, 0.0, 1, 0), node(100.0, 0.0, 1, 0), node(50.0, 80.0, 1, 0), node(0.0, 0.0, 1, 0)]),
]),
)],
),
),
(tag(b"BFFl"), F::Shared(vec![solid(21, rgba(1.0, 0.0, 0.0, 1.0))])),
(tag(b"LIFl"), F::Shared(vec![solid(24, rgba(0.0, 0.0, 1.0, 1.0))])),
(
tag(b"LILn"),
F::Shared(vec![F::Def(
27,
vec![tag(b"LDsc")],
vec![(tag(b"LDeL"), F::Def(28, vec![tag(b"LSty")], vec![(tag(b"Wght"), F::F64(4.0))])), (tag(b"LDSc"), F::Bool(true))],
)]),
),
],
);
let board = F::Def(
10,
vec![tag(b"ShpN"), tag(b"VNod"), tag(b"Node")],
vec![
(tag(b"Desc"), F::Str("Board".into())),
(tag(b"ABEn"), F::Bool(true)),
(tag(b"Shpe"), F::Def(11, vec![tag(b"ShNR")], vec![])),
(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 200.0, 100.0])),
(tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, 20.0, 0.0, 1.0, 10.0])),
(tag(b"BFFl"), F::Shared(vec![solid(12, rgba(1.0, 1.0, 1.0, 1.0))])),
(tag(b"Chld"), F::Shared(vec![triangle])),
],
);
let ellipse = F::Def(
40,
vec![tag(b"ShpN")],
vec![
(tag(b"Visi"), F::Bool(false)),
(tag(b"Shpe"), F::Def(41, vec![tag(b"ShpE")], vec![])),
(tag(b"ShpB"), F::F64s(vec![300.0, 0.0, 400.0, 50.0])),
(tag(b"BFFl"), F::Shared(vec![F::Def(42, vec![tag(b"FDsc")], vec![(tag(b"FDeF"), F::Def(43, vec![tag(b"FilN")], vec![]))])])),
],
);
let layer = F::Def(30, vec![tag(b"Scop")], vec![(tag(b"Desc"), F::Str("Shapes".into())), (tag(b"Chld"), F::Shared(vec![ellipse]))]);
let spread =
F::Def(2, vec![tag(b"Sprd")], vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 420.0, 120.0])), (tag(b"Chld"), F::Shared(vec![board, layer]))]);
let doc = synth::stream(&[
(tag(b"UVCn"), F::Obj(tag(b"UVCn"), vec![(tag(b"UPPI"), F::F64(144.0))])),
(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))])),
]);
synth::container(&[("doc.dat", &doc, method)], Some(&png()))
}
#[test]
fn layers_artboards_curves_and_paints_open_as_editable_art() {
for method in [Method::Stored, Method::Zlib, Method::Zstd] {
let mut s = Session::new();
let r = open_bytes(&mut s, "native.af", &document(method), Some("/source/native.af".into())).unwrap();
assert_eq!(r["format"], "affinity");
// Affinity Photo's extension isn't listed, but its documents still open by their content.
assert_eq!(open_bytes(&mut Session::new(), "native.afphoto", &document(method), None).unwrap()["format"], "affinity");
assert!(r["warnings"].as_array().unwrap().iter().all(|w| !w.as_str().unwrap().contains("preview")), "{r}");
let st = s.doc().unwrap();
assert!(st.path.is_none(), "Save must not write back over the Affinity file");
let d = &st.doc;
// 144 ppi: two document pixels per point.
assert_eq!(d.artboards.len(), 1);
assert_eq!(d.artboards[0].name, "Board");
assert_eq!(d.artboards[0].rect, kurbo::Rect::new(10.0, 5.0, 110.0, 55.0));
let names: Vec<_> = d.layers.iter().map(|l| l.name.clone().unwrap()).collect();
assert_eq!(names, ["Board", "Shapes"]);
let NodeKind::Layer { children, .. } = &d.layers[0].kind else { panic!() };
let NodeKind::Group { children: clipped, clip: true } = &children[0].kind else { panic!("{:?}", children[0].kind) };
let NodeKind::Path { clipping: true, .. } = &clipped[0].kind else { panic!() };
let tri = &clipped[1];
assert_eq!(tri.name.as_deref(), Some("Triangle"));
let NodeKind::Path { path, .. } = &tri.kind else { panic!() };
let pts: Vec<_> = path.subpaths[0].anchors.iter().map(|a| (a.p.x, a.p.y)).collect();
assert_eq!(pts, [(10.0, 5.0), (60.0, 5.0), (35.0, 45.0)]);
assert!(path.subpaths[0].closed);
let [AppearanceItem::Fill(f), AppearanceItem::Stroke(k)] = tri.appearance.items.as_slice() else { panic!("{:?}", tri.appearance) };
assert_eq!(f.paint, vectorcraft_color::Paint::solid(vectorcraft_color::Color::Rgb { r: 1.0, g: 0.0, b: 0.0 }));
assert_eq!(k.width, 2.0);
let NodeKind::Layer { children, .. } = &d.layers[1].kind else { panic!() };
assert!(!children[0].visible, "hidden layers stay hidden");
}
}
#[test]
fn native_documents_save_reopen_export_place_and_template() {
let bytes = document(Method::Zstd);
let mut s = Session::new();
open_bytes(&mut s, "native.af", &bytes, None).unwrap();
let doc = s.doc().unwrap().doc.clone();
let reopened = vectorcraft_format::load_file(&vectorcraft_format::save_file(&doc)).unwrap();
assert_eq!(reopened.doc.layers, doc.layers);
for format in ["svg", "pdf", "png"] {
assert!(s.execute("document.export", &json!({"format": format})).is_ok(), "{format}");
}
let p = json!({"name":"native.afdesign", "dataBase64":vectorcraft_format::base64_encode(&bytes)});
s.execute("file.new", &json!({"width":100,"height":100})).unwrap();
s.execute("file.place", &p).unwrap();
assert!(s.doc().unwrap().doc.layers.iter().any(|l| !l.children().unwrap().is_empty()));
s.execute("file.newFromTemplate", &p).unwrap();
assert!(s.doc().unwrap().path.is_none());
}
#[test]
fn a_damaged_native_document_falls_back_to_its_preview_with_the_reason() {
let mut bytes = document(Method::Zlib);
// Break the doc.dat CRC: the preview still opens, saying why.
bytes[80] ^= 0xFF;
let r = load("broken.af", &bytes).unwrap();
assert!(r.preview_only);
assert!(r.warnings[0].contains("could not be read (damaged Affinity file"), "{}", r.warnings[0]);
}
}
+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);
@@ -147,3 +147,19 @@ fn the_document_background_is_the_default_export_background() {
assert_eq!(png(&mut s, json!({"format": "png", "background": "transparent"})).get_pixel(0, 0)[3], 0, "the option wins");
assert_eq!(png(&mut s, json!({"format": "webp", "background": "black"})).get_pixel(0, 0).0, [0, 0, 0, 255]);
}
#[test]
fn an_image_opens_at_the_size_its_resolution_declares() {
let mut s = session(72.0, 36.0, 1);
let hi = b64(&s.execute("document.export", &json!({"format": "png", "ppi": 300, "background": "white"})).unwrap());
let lo = b64(&s.execute("document.export", &json!({"format": "png", "background": "white"})).unwrap());
let lo = super::ppi::with_png_resolution(&lo, (72.0, 72.0));
for (png, w, h, what) in [(hi, 72.0, 36.0, "300 ppi: 300 x 150 px is 1 x 0.5 in"), (lo, 72.0, 36.0, "72 ppi: 1 px = 1 pt")] {
s.execute("document.open", &json!({"name": "art.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap();
let doc = &s.doc().unwrap().doc;
let ab = doc.artboards[0].rect;
assert!((ab.width() - w).abs() < 1e-6 && (ab.height() - h).abs() < 1e-6, "{what}: artboard {ab:?}");
let art = doc.art_bounds().expect("the image");
assert!((art.width() - w).abs() < 1e-6 && (art.height() - h).abs() < 1e-6, "{what}: the image fills it, {art:?}");
}
}
+1 -1
View File
@@ -1265,11 +1265,11 @@ fn reach(n: &Node) -> Option<Rect> {
if !n.visible {
return None;
}
let fx = n.appearance.items.iter().map(|i| effects::outset(i.effects())).fold(effects::outset(&n.appearance.effects), f64::max);
let b = match n.children() {
Some(ch) if !n.clips() && !matches!(n.kind, NodeKind::Compound { .. }) => ch.iter().filter_map(|c| reach(c)).reduce(|a, b| a.union(b))?,
_ => n.visual_bounds()?,
};
let fx = n.appearance.items.iter().map(|i| effects::outset(i.effects(), b)).fold(effects::outset(&n.appearance.effects, b), f64::max);
Some(b.inflate(fx + 1.0, fx + 1.0))
}
+16 -1
View File
@@ -247,7 +247,7 @@ fn font_list(_: &mut Session, p: &Value) -> Result<Value> {
}
}
fn rescan(s: &mut Session, _: &Value) -> Result<Value> {
pub(crate) fn rescan(s: &mut Session, _: &Value) -> Result<Value> {
let db = vectorcraft_text::FontDb::global();
#[cfg(not(target_arch = "wasm32"))]
let faces = db.load_system_fonts();
@@ -283,6 +283,21 @@ fn rescan(s: &mut Session, _: &Value) -> Result<Value> {
mod tests {
use super::*;
/// Type › Options › Additional Fonts Folder (#683) adds its folder to the system font scan,
/// trimmed; clearing it takes the folder out again.
#[test]
fn the_additional_fonts_folder_joins_the_font_scan() {
let mut s = Session::new();
let dir = std::env::temp_dir().join("vectorcraft-fonts-folder-pref");
s.execute("prefs.set", &json!({"key": "fontsFolder", "value": format!(" {} ", dir.display())})).unwrap();
assert_eq!(vectorcraft_text::user_font_dirs(), std::slice::from_ref(&dir));
if !cfg!(target_arch = "wasm32") {
assert!(vectorcraft_text::system_font_dirs().contains(&dir));
}
s.execute("prefs.set", &json!({"key": "fontsFolder", "value": ""})).unwrap();
assert!(vectorcraft_text::user_font_dirs().is_empty() && !vectorcraft_text::system_font_dirs().contains(&dir));
}
#[test]
fn list_flag_missing_replace_and_select() {
let mut s = Session::new();
+109
View File
@@ -0,0 +1,109 @@
//! Inline graphics in text: document symbols placed in a run of text, flowing like a glyph
//! (beyond Illustrator; InDesign's inline anchored objects). See [`vectorcraft_doc::InlineArt`].
use serde_json::{Value, json};
use vectorcraft_doc::text::INLINE_MAX_SCALE;
use vectorcraft_doc::{Document, InlineArt, NodeId, NodeKind, TextRun};
use vectorcraft_text::edit;
use super::typecmd::refresh_bounds;
use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"text.insertInline",
"Insert Inline Symbol",
["Type"],
None,
"{id?: text id (default: the text the Type tool edits), at?: byte offset (default: the Type tool's selection, which it replaces; clamped to the text), symbol?: symbol name (default: the Symbols panel's current symbol), scale?: art height ÷ font size = 1 (0 < scale ≤ 100), shift?: pt raise = 0} place a document symbol inline in the text as one character (U+FFFC) that flows like a glyph: its art is scaled to `scale` × the font size tall, its vertical centre on the middle of the cap height raised by `shift`. Typing, Backspace and Delete over it remove it whole → {id, caret}",
has_doc,
insert_inline
)]
}
/// Inline graphics follow their symbols: after an edit of a document that has (or had) symbols,
/// resolve the inline art of every text again (only texts whose art changed are touched).
pub(crate) fn refresh(before: &Document, d: &mut Document) {
if before.symbols.is_empty() && d.symbols.is_empty() {
return;
}
for id in d.resolve_inline_art() {
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
refresh_bounds(t);
}
}
}
fn insert_inline(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.insertInline";
let tool = s.tool_options();
let editing = tool.get("editing").and_then(Value::as_u64).map(NodeId);
let id = match p.get("id") {
None | Some(Value::Null) => editing.ok_or_else(|| bad(C, "give `id` (no text is being edited)"))?,
Some(_) => id_param(p, "id").ok_or_else(|| bad(C, "`id` must be a node id"))?,
};
let doc = &s.doc()?.doc;
let NodeKind::Text(t) = &doc.node(id).ok_or(EngineError::NoNode(id))?.kind else {
return Err(bad(C, format!("node {} is not text", id.0)));
};
let len = edit::runs_len(&t.runs);
let from_tool = editing == Some(id);
let (a, b) = match p.get("at") {
None | Some(Value::Null) if from_tool => {
let g = |k: &str| tool.get(k).and_then(Value::as_u64).map_or(len, |v| (v as usize).min(len));
let (x, y) = (g("start"), g("end"));
(x.min(y), x.max(y))
}
None | Some(Value::Null) => (len, len),
Some(v) => {
let at = v.as_u64().ok_or_else(|| bad(C, "`at` must be a byte offset"))?.min(len as u64) as usize;
(at, at)
}
};
let symbol = match str_param(p, "symbol") {
Some(n) if doc.symbols.iter().any(|s| s.name == n) => n.to_string(),
Some(n) => return Err(bad(C, format!("no symbol named `{n}`"))),
None if p.get("symbol").is_some_and(|v| !v.is_null()) => return Err(bad(C, "`symbol` must be a symbol name")),
None => current_symbol(doc).ok_or_else(|| EngineError::Other("the document has no symbols".into()))?,
};
let scale = match p.get("scale") {
None | Some(Value::Null) => 1.0,
Some(v) => v
.as_f64()
.filter(|k| k.is_finite() && *k > 0.0 && *k <= INLINE_MAX_SCALE)
.ok_or_else(|| bad(C, "`scale` must be a number in (0, 100]"))?,
};
let shift = match p.get("shift") {
None | Some(Value::Null) => 0.0,
Some(v) => v.as_f64().filter(|k| k.is_finite() && k.abs() <= 1e5).ok_or_else(|| bad(C, "`shift` must be a number of points"))?,
};
// A typing session ends first: the insertion is a step of its own.
let typing = tool.get("typing").and_then(Value::as_bool) == Some(true);
if typing && from_tool {
s.set_tool_option("commitTyping", &Value::Bool(true));
s.commit_interaction()?;
}
let caret = s.edit("Insert Inline Symbol", |d, _| {
let mut art = InlineArt { scale, baseline_shift: shift, ..InlineArt::new(symbol.clone()) };
art.bounds = d.symbol_art_bounds(&symbol);
let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) };
let style = edit::insertion_style(&t.runs, a, b);
let caret = edit::replace_range_styled(&mut t.runs, a, b, &[TextRun::inline(art, style)]);
refresh_bounds(t);
Ok(caret)
})?;
if from_tool {
s.set_tool_option("select", &json!({"start": caret, "end": caret}));
}
Ok(json!({"id": id.0, "caret": caret}))
}
/// The Symbols panel's current symbol, else the first one.
fn current_symbol(d: &Document) -> Option<String> {
d.unknown
.get("currentSymbol")
.and_then(Value::as_str)
.filter(|n| d.symbols.iter().any(|s| s.name == *n))
.map(str::to_string)
.or_else(|| d.symbols.first().map(|s| s.name.clone()))
}
+78 -10
View File
@@ -111,7 +111,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Crop Image",
["Object"],
None,
"{rect?: [x, y, width, height]} crop the selected image (default: to the artboard it sits on) → {id, width, height}",
"{rect?: [x, y, width, height] | trim?: true (to the pixels that aren't fully transparent, pixel for pixel; an image with none to cut is left as it is, trimmed: false)} crop the selected image (default: to the artboard it sits on) → {id, width, height, trimmed? (with trim)}",
has_image,
crop_image
),
@@ -156,7 +156,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Rearrange All Artboards…",
["Object", "Artboards"],
None,
"{columns?: n (2), spacing?: pt (20), byColumn?: false, moveArtwork?: true (locked and hidden art only with prefs moveLockedWithArtboard)} lay artboards out in a grid",
"{layout?: gridByRow|gridByColumn|row|column (gridByRow; byColumn?: true is gridByColumn), order?: leftToRight|rightToLeft (leftToRight: right-to-left mirrors the order along the rows), columns?: n (2; the rows for gridByColumn; row and column ignore it), spacing?: pt (20), moveArtwork?: true (locked and hidden art only with prefs moveLockedWithArtboard)} lay the artboards out in their order, the grid's top-left at the first artboard's → {artboards, rows, columns}",
has_doc,
rearrange_artboards
),
@@ -541,6 +541,9 @@ fn crop_image(s: &mut Session, p: &Value) -> Result<Value> {
.ok_or_else(|| bad(C, "select an image"))?;
let node = st.doc.node(id).cloned().ok_or(EngineError::NoNode(id))?;
let NodeKind::Image(im) = &node.kind else { return Err(bad(C, "select an image")) };
if p.get("trim").and_then(Value::as_bool) == Some(true) {
return trim_image(s, id, im.clone());
}
let ib = node.geometric_bounds().ok_or_else(|| bad(C, "image has no bounds"))?;
let rect = match p.get("rect").and_then(Value::as_array) {
Some(a) if a.len() == 4 => {
@@ -594,6 +597,49 @@ fn crop_image(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "id": id.0, "width": w, "height": h }))
}
/// Crop Image's trim: the image cut to the box of its pixels that aren't fully transparent, the
/// pixels copied as they are (no resampling) and placed where they were.
fn trim_image(s: &mut Session, id: NodeId, im: ImageObject) -> Result<Value> {
const C: &str = "object.cropImage";
let st = s.doc()?;
let blob = st.doc.images.get(&im.key).ok_or_else(|| bad(C, "the image's pixels are missing"))?;
let px = image::load_from_memory(&blob.bytes).map_err(|_| bad(C, "the image's pixels can't be read"))?.to_rgba8();
let (w, h) = px.dimensions();
let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0, 0);
for (x, y, p) in px.enumerate_pixels() {
if p[3] > 0 {
(x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x + 1), y1.max(y + 1));
}
}
if x1 <= x0 || y1 <= y0 {
return Err(bad(C, "the image is fully transparent"));
}
if (x0, y0, x1, y1) == (0, 0, w, h) {
// Already trimmed: not an error, so a script can trim every image it opens.
return Ok(json!({ "id": id.0, "width": w, "height": h, "trimmed": false }));
}
let cut = image::imageops::crop_imm(&px, x0, y0, x1 - x0, y1 - y0).to_image();
let mut png = Vec::new();
cut.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).map_err(|e| bad(C, e.to_string()))?;
let (cw, ch) = (x1 - x0, y1 - y0);
s.edit("Crop Image", |d, _| {
let key = unique_key(d, "crop");
d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", png));
if let Some(n) = d.node_mut(id) {
n.kind = NodeKind::Image(ImageObject {
key,
width: cw,
height: ch,
xf: im.xf * Affine::translate((f64::from(x0), f64::from(y0))),
link: None,
placement: Default::default(),
});
}
Ok(())
})?;
Ok(json!({ "id": id.0, "width": cw, "height": ch, "trimmed": true }))
}
/// The Control bar's Mask for an image: a clip group of the image and a clipping path on its
/// outline, with that path selected so dragging its handles crops the image.
fn mask_image(s: &mut Session, _: &Value) -> Result<Value> {
@@ -777,10 +823,32 @@ fn convert_to_artboards(s: &mut Session, _: &Value) -> Result<Value> {
}
fn rearrange_artboards(s: &mut Session, p: &Value) -> Result<Value> {
let n_ab = s.doc()?.doc.artboards.len().max(1);
let cols = (f64_or(p, "columns", 2.0).clamp(1.0, n_ab as f64)) as usize;
const CMD: &str = "artboard.rearrange";
use super::newdoc::ArtboardLayout as L;
// A value given (not null) must be one of the names.
let named = |key: &str| p.get(key).filter(|v| !v.is_null()).map(|v| v.as_str().unwrap_or_default());
let layout = match named("layout") {
Some(l) => L::parse(l).ok_or_else(|| bad(CMD, "layout must be gridByRow, gridByColumn, row or column"))?,
None if bool_or(p, "byColumn", false) => L::GridByColumn,
None => L::GridByRow,
};
let rtl = match named("order") {
None => false,
Some(o) if o.eq_ignore_ascii_case("leftToRight") => false,
Some(o) if o.eq_ignore_ascii_case("rightToLeft") => true,
Some(_) => return Err(bad(CMD, "order must be leftToRight or rightToLeft")),
};
let count = s.doc()?.doc.artboards.len();
let n_ab = count.max(1);
// Columns of a grid by row, rows of a grid by column (NaN casts to 0).
let lines = (f64_or(p, "columns", 2.0).clamp(1.0, n_ab as f64) as usize).max(1);
let (rows, cols) = match layout {
L::GridByRow => (n_ab.div_ceil(lines), lines),
L::GridByColumn => (lines, n_ab.div_ceil(lines)),
L::Row => (1, n_ab),
L::Column => (n_ab, 1),
};
let spacing = f64_or(p, "spacing", 20.0).clamp(-1.0e5, 1.0e5);
let by_col = bool_or(p, "byColumn", false);
let move_art = bool_or(p, "moveArtwork", true);
let locked_and_hidden = s.prefs.move_locked_with_artboard;
let scale_strokes = false;
@@ -788,7 +856,7 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result<Value> {
let rects: Vec<Rect> = d.artboards.iter().map(|a| a.rect).collect();
let Some(first) = rects.first().copied() else { return Ok(()) };
let sizes: Vec<(f64, f64)> = rects.iter().map(|r| (r.width(), r.height())).collect();
let origins = super::newdoc::grid_origins(&sizes, first.origin(), cols, spacing, by_col, false);
let origins = super::newdoc::grid_origins_rc(&sizes, first.origin(), (rows, cols), spacing, layout == L::GridByColumn, rtl);
let deltas: Vec<Vec2> = rects.iter().zip(origins).map(|(r, o)| o - r.origin()).collect();
if move_art {
let tops: Vec<(NodeId, Point)> = d
@@ -800,11 +868,11 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result<Value> {
.filter_map(|n| Some((n.id, n.geometric_bounds()?.center())))
.collect();
for (id, c) in tops {
if let Some(i) = rects.iter().position(|r| r.contains(c))
&& deltas[i] != Vec2::ZERO
if let Some(&dl) = rects.iter().position(|r| r.contains(c)).and_then(|i| deltas.get(i))
&& dl != Vec2::ZERO
&& let Some(n) = d.node_mut(id)
{
n.transform(Affine::translate(deltas[i]), scale_strokes);
n.transform(Affine::translate(dl), scale_strokes);
}
}
}
@@ -818,5 +886,5 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok(())
})?;
ok()
Ok(json!({ "artboards": count, "rows": rows, "columns": cols }))
}
+2
View File
@@ -29,6 +29,7 @@ pub(crate) mod gradient;
pub(crate) mod graph;
mod halftone;
pub mod help;
pub(crate) mod inline;
mod layer;
mod layerpanel;
pub mod links;
@@ -218,6 +219,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(colormgmt::specs());
v.extend(typemenu::specs());
v.extend(textedit::specs());
v.extend(inline::specs());
v.extend(textstyles::specs());
v.extend(fonts::specs());
v.extend(help::specs());
+17 -4
View File
@@ -345,9 +345,22 @@ pub fn color_mode_id(m: ColorMode) -> &'static str {
/// each column as wide and each row as tall as its largest artboard; `by_column` fills columns
/// top to bottom first, `rtl` puts the first column at the right.
pub(crate) fn grid_origins(sizes: &[(f64, f64)], origin: Point, columns: usize, spacing: f64, by_column: bool, rtl: bool) -> Vec<Point> {
let n = sizes.len();
let cols = columns.clamp(1, n.max(1));
let rows = n.div_ceil(cols).max(1);
let n = sizes.len().max(1);
let cols = columns.clamp(1, n);
grid_origins_rc(sizes, origin, (n.div_ceil(cols), cols), spacing, by_column, rtl)
}
/// [`grid_origins`] on a grid of `rows` × `cols` cells (at least enough for every artboard when
/// filled the `by_column` way).
pub(crate) fn grid_origins_rc(
sizes: &[(f64, f64)],
origin: Point,
(rows, cols): (usize, usize),
spacing: f64,
by_column: bool,
rtl: bool,
) -> Vec<Point> {
let (rows, cols) = (rows.max(1), cols.max(1));
// Grid cell (row, column) of artboard i.
let cell = |i: usize| {
let (r, c) = if by_column { (i % rows, i / rows) } else { (i / cols, i % cols) };
@@ -362,7 +375,7 @@ pub(crate) fn grid_origins(sizes: &[(f64, f64)], origin: Point, columns: usize,
}
}
let offset = |spans: &[f64], k: usize| spans.iter().take(k).map(|s| s + spacing).sum::<f64>();
(0..n)
(0..sizes.len())
.map(|i| {
let (r, c) = cell(i);
Point::new(origin.x + offset(&col_w, c), origin.y + offset(&row_h, r))
+1 -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\" (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), 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
),
+12 -1
View File
@@ -136,7 +136,8 @@ pub(crate) fn pt_per_px(ppi: Option<(f64, f64)>) -> (f64, f64) {
fn load(p: &Value, cmd: &str, board: Option<Rect>) -> Result<Loaded> {
let src = fileio::source(p, cmd)?;
let name = fileio::file_name(src.name);
if fileio::TEXT_EXTS.contains(&fileio::extension(src.name).as_str()) {
// An Affinity file is recognised by its signature, whatever its name says.
if fileio::TEXT_EXTS.contains(&fileio::extension(src.name).as_str()) && !vectorcraft_affinity::is_affinity(&src.bytes) {
let text = text::import(&src.bytes, text::TextOptions::parse(p, cmd)?, cmd)?;
if text.trim().is_empty() {
return Err(bad(cmd, format!("`{name}` has no text to place")));
@@ -196,6 +197,16 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>) -> Result<Loaded> {
// Of the open options, only a DXF drawing's apply (the colour mode stays the file's).
let o = fileio::LoadOptions { dxf: opts.dxf, ..Default::default() };
let mut l = fileio::load_with(src.name, &src.bytes, &o)?;
// An Affinity file whose native document couldn't be read is only its preview: it
// must never silently stand in for the art.
if l.preview_only {
return Err(bad(
cmd,
format!(
"{name}: only this Affinity file's embedded preview could be read; use File › Open to see it with its warning, or export SVG or PDF from Affinity before placing"
),
));
}
// Linked images show their files, found from its folder.
super::links::resolve(&mut l.doc, src.path, false);
(l.doc, l.warnings)
+26 -1
View File
@@ -187,6 +187,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[
p!("missingGlyphProtection", "Type", "Options", "Enable Missing Glyph Protection", bool),
p!("highlightAlternateGlyphs", "Type", "Options", "Highlight Alternate Glyphs", bool),
p!("placeholderText", "Type", "Options", "Fill New Type Objects With Placeholder Text", bool),
p!("fontsFolder", "Type", "Options", "Additional Fonts Folder", text),
// Units
p!("unitsGeneral", "Units", "", "General", choice(UNITS)),
p!("unitsStroke", "Units", "", "Stroke", choice(UNITS)),
@@ -260,8 +261,21 @@ pub const PREF_SPECS: &[PrefSpec] = &[
"Brightness",
choice(&[("dark", "Dark"), ("mediumDark", "Medium Dark"), ("mediumLight", "Medium Light"), ("light", "Light")])
),
p!("canvasColor", "User Interface", "", "Canvas Color", choice(&[("matchUi", "Match User Interface Brightness"), ("white", "White")])),
p!(
"canvasColor",
"User Interface",
"",
"Canvas Color",
choice(&[
("matchUi", "Match User Interface Brightness"),
("white", "White"),
("lightGray", "Light Gray"),
("mediumGray", "Medium Gray"),
("darkGray", "Dark Gray")
])
),
p!("autoCollapseIconPanels", "User Interface", "", "Auto-Collapse Iconic Panels", bool),
p!("toolGroupLabels", "User Interface", "", "Show Tool Group Labels", bool),
p!("openDocumentsAsTabs", "User Interface", "", "Open Documents As Tabs", bool),
p!("largeTabs", "User Interface", "", "Large Tabs", bool),
p!("uiScaling", "User Interface", "UI Scaling", "Scale", num(0.75, 2.0, "×")),
@@ -460,7 +474,18 @@ impl Session {
let grid_changed = p.gridline_every != self.prefs.gridline_every || p.grid_subdivisions != self.prefs.grid_subdivisions;
let history_changed = p.history_states != self.prefs.history_states;
let tile_edge_changed = p.pattern_tile_edge_color != self.prefs.pattern_tile_edge_color;
let fonts_folder_changed = p.fonts_folder != self.prefs.fonts_folder;
self.prefs = p;
if fonts_folder_changed {
let folder = self.prefs.fonts_folder.trim();
vectorcraft_text::set_user_font_dirs(if folder.is_empty() { vec![] } else { vec![folder.into()] });
// Once the fonts were scanned, scan again now: the folder's fonts appear (or go) in the
// font menus, and type in them lays out again. The first scan reads it anyway.
if vectorcraft_text::FontDb::global().installed_fonts_changed() {
// Its result only counts the faces cataloged.
let _ = super::fonts::rescan(self, &serde_json::Value::Null);
}
}
vectorcraft_render::set_default_threads(u16::try_from(self.prefs.render_threads).ok());
for st in &mut self.docs {
if history_changed {
+59 -25
View File
@@ -7,6 +7,10 @@
//! effect: the image (with the object's own area knocked out) goes under the untouched vector
//! object. Effects that change the object itself (inner glow, feather, Gaussian blur) replace it
//! with the image. Expand Appearance makes its images with the same helpers.
//!
//! SVG has filters for shadows, glows, blurs and feathers but none for the Photoshop-style
//! effects (Radial Blur, Unsharp Mask…): SVG export turns only the objects carrying one of those
//! into images the same way ([`flatten_pixel_effects`]).
use std::sync::Arc;
@@ -167,7 +171,7 @@ pub(crate) fn effect_image(out: &mut Document, whole: &Node, knockout: Option<&N
// effects reach beyond that.
let b = vectorcraft_render::painted_bounds(whole)?;
let look = out.raster_effects.clone();
let reach = whole.appearance.items.iter().map(|i| effects::outset(i.effects())).fold(0.0, f64::max) + 2.0 + look.add_around;
let reach = whole.appearance.items.iter().map(|i| effects::outset(i.effects(), b)).fold(0.0, f64::max) + 2.0 + look.add_around;
let b = b.inflate(reach, reach);
// Whole pixels, and no larger than 64 Mpx.
let scale = scale.min((64.0e6 / (b.width() * b.height()).max(1.0)).sqrt());
@@ -337,20 +341,39 @@ fn placed_symbol(d: &Document, n: &Node) -> Option<Node> {
Some(Node { kind: NodeKind::Group { children: vec![Arc::new(art)], clip: false }, ..n.clone() })
}
/// Does `n` hold raster effects PDF export draws: its own, its fills' and strokes', its opacity
/// mask's or (groups and layers) its members'? Hidden objects and template layers aren't drawn.
fn needs(n: &Node) -> bool {
/// Which objects' raster effects become images.
#[derive(Clone, Copy, PartialEq)]
enum Which {
/// Every raster effect (PDF).
All,
/// Objects with a Photoshop-style effect, which SVG can't draw (with all their raster effects).
Pixel,
}
/// Has `n` (its own, its fills' or strokes') raster effects `which` turns into an image?
fn imaged(n: &Node, which: Which) -> bool {
let fx = raster_fx(n);
match which {
Which::All => !fx.is_empty(),
Which::Pixel => fx.iter().any(|f| matches!(f, RasterFx::Pixel(_))),
}
}
/// Does `n` hold raster effects that `which` turns into images: its own, its fills' and strokes',
/// its opacity mask's or (groups and layers) its members'? Hidden objects and template layers
/// aren't drawn.
fn needs(n: &Node, which: Which) -> bool {
n.visible
&& !matches!(n.kind, NodeKind::Layer { template: true, .. })
&& (!raster_fx(n).is_empty()
|| n.mask.as_ref().is_some_and(|m| needs(&m.art))
|| (is_container(n) && n.children().is_some_and(|ch| ch.iter().any(|c| needs(c)))))
&& (imaged(n, which)
|| n.mask.as_ref().is_some_and(|m| needs(&m.art, which))
|| (is_container(n) && n.children().is_some_and(|ch| ch.iter().any(|c| needs(c, which)))))
}
/// The art of `n`'s opacity mask with its raster effects as images.
fn walk_mask(out: &mut Document, n: &mut Node) {
fn walk_mask(out: &mut Document, n: &mut Node, which: Which) {
if let Some(m) = n.mask.as_mut()
&& let Some(art) = walk(out, &m.art)
&& let Some(art) = walk(out, &m.art, which)
{
m.art = Arc::new(art);
}
@@ -359,15 +382,15 @@ fn walk_mask(out: &mut Document, n: &mut Node) {
/// `n` with its raster effects (and its members' and its opacity mask's) as images, `None` when it
/// has none. An object whose effects all paint below it keeps its vector art over an image of
/// them; one whose effects change it (blur, feather, inner glow) becomes the image.
fn walk(out: &mut Document, n: &Node) -> Option<Node> {
if !needs(n) {
fn walk(out: &mut Document, n: &Node, which: Which) -> Option<Node> {
if !needs(n, which) {
return None;
}
if raster_fx(n).is_empty() {
if !imaged(n, which) {
let mut m = n.clone();
walk_mask(out, &mut m);
walk_mask(out, &mut m, which);
if is_container(&m)
&& let Some(ch) = walk_all(out, n.children()?)
&& let Some(ch) = walk_all(out, n.children()?, which)
&& let Some(slot) = m.children_mut()
{
*slot = ch;
@@ -375,30 +398,30 @@ fn walk(out: &mut Document, n: &Node) -> Option<Node> {
return Some(m);
}
if let Some(g) = placed_symbol(out, n) {
return Some(walk(out, &g).unwrap_or(g));
return Some(walk(out, &g, which).unwrap_or(g));
}
let below = below_only(n);
if !below && let Some(g) = split_items(out, n) {
return Some(walk(out, &g).unwrap_or(g));
return Some(walk(out, &g, which).unwrap_or(g));
}
// An image that can't be made leaves the object as it is (the writer reports its effects).
let image = raster_image(out, n, below, false)?;
let mut v = n.clone();
strip_raster(&mut v, false);
let mut m = if below {
let mut m = walk(out, &v).unwrap_or(v);
let mut m = walk(out, &v, which).unwrap_or(v);
put_below(out, &mut m, image);
m
} else {
replace_with_image(v, image)
};
walk_mask(out, &mut m);
walk_mask(out, &mut m, which);
Some(m)
}
/// `nodes` with their raster effects as images, `None` when none has any.
fn walk_all(out: &mut Document, nodes: &[Arc<Node>]) -> Option<Vec<Arc<Node>>> {
nodes.iter().any(|n| needs(n)).then(|| nodes.iter().map(|n| walk(out, n).map(Arc::new).unwrap_or_else(|| n.clone())).collect())
fn walk_all(out: &mut Document, nodes: &[Arc<Node>], which: Which) -> Option<Vec<Arc<Node>>> {
nodes.iter().any(|n| needs(n, which)).then(|| nodes.iter().map(|n| walk(out, n, which).map(Arc::new).unwrap_or_else(|| n.clone())).collect())
}
/// A copy of `doc` with raster effects turned into images at the document's raster effects
@@ -406,26 +429,37 @@ fn walk_all(out: &mut Document, nodes: &[Arc<Node>]) -> Option<Vec<Arc<Node>>> {
/// type, images, symbol instances, live objects) and on single fills and strokes, in the layers,
/// opacity masks, symbol definitions and pattern tiles.
pub fn flatten_raster_effects(doc: &Document) -> Option<Document> {
let any = |nodes: &[Arc<Node>]| nodes.iter().any(|n| needs(n));
if !any(&doc.layers) && !doc.symbols.iter().any(|s| needs(&s.art)) && !doc.patterns.iter().any(|p| any(&p.art)) {
flatten(doc, Which::All)
}
/// A copy of `doc` in which the objects with Photoshop-style raster effects (Radial Blur, Unsharp
/// Mask…) are images as [`flatten_raster_effects`] makes them (SVG export: its filters draw the
/// other raster effects live), or `None` when there are none.
pub fn flatten_pixel_effects(doc: &Document) -> Option<Document> {
flatten(doc, Which::Pixel)
}
fn flatten(doc: &Document, which: Which) -> Option<Document> {
let any = |nodes: &[Arc<Node>]| nodes.iter().any(|n| needs(n, which));
if !any(&doc.layers) && !doc.symbols.iter().any(|s| needs(&s.art, which)) && !doc.patterns.iter().any(|p| any(&p.art)) {
return None;
}
// Geometry effects first, so the vector objects kept above shadows are final.
let baked = effects::bake_document(doc);
let src = baked.as_ref().unwrap_or(doc);
let mut out = src.clone();
if let Some(layers) = walk_all(&mut out, &src.layers) {
if let Some(layers) = walk_all(&mut out, &src.layers, which) {
out.layers = layers;
}
for i in 0..out.symbols.len() {
if let Some(art) = out.symbols.get(i).map(|s| s.art.clone()).and_then(|a| walk(&mut out, &a))
if let Some(art) = out.symbols.get(i).map(|s| s.art.clone()).and_then(|a| walk(&mut out, &a, which))
&& let Some(s) = out.symbols.get_mut(i)
{
s.art = Arc::new(art);
}
}
for i in 0..out.patterns.len() {
if let Some(art) = out.patterns.get(i).map(|p| p.art.clone()).and_then(|a| walk_all(&mut out, &a))
if let Some(art) = out.patterns.get(i).map(|p| p.art.clone()).and_then(|a| walk_all(&mut out, &a, which))
&& let Some(p) = out.patterns.get_mut(i)
{
p.art = art;
+7 -4
View File
@@ -70,8 +70,8 @@ pub fn specs() -> Vec<CommandSpec> {
impl LibraryFile for StyleLibrary {
const EXTS: &'static [&'static str] = &[STYLES_EXT];
fn read(text: &str, stem: &str) -> std::result::Result<Self, String> {
style_libs::read(text, stem)
fn read(bytes: &[u8], stem: &str) -> std::result::Result<Self, String> {
style_libs::read(&String::from_utf8_lossy(bytes), stem)
}
fn name(&self) -> &str {
&self.name
@@ -222,8 +222,11 @@ fn load(s: &mut Session, p: &Value) -> Result<Value> {
match std::str::from_utf8(bytes).ok().filter(|t| style_libs::sniff(t)) {
Some(t) => style_libs::read(t, stem(file)).map_err(|e| bad(C, e)),
None => {
let doc = super::fileio::load(file, bytes).map_err(|e| bad(C, e.to_string()))?.doc;
StyleLibrary::from_document(&doc, &[], stem(file).to_string()).map_err(|e| bad(C, e))
let loaded = super::fileio::load(file, bytes).map_err(|e| bad(C, e.to_string()))?;
if loaded.preview_only {
return Err(bad(C, "only this Affinity file's embedded preview could be read; use File › Open to see it with its warning"));
}
StyleLibrary::from_document(&loaded.doc, &[], stem(file).to_string()).map_err(|e| bad(C, e))
}
}
})?;
+34 -16
View File
@@ -69,7 +69,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Other Library…",
["Window", "Swatch Libraries"],
None,
"{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} load a .vcswatches or .gpl library, or the swatches of any document VectorCraft opens (see document.formats), for the library panel (Window → Swatch Libraries lists it until the app quits) → {library: id, name, count}",
"{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} load a .vcswatches, .gpl or .ase (swatch exchange) library, or the swatches of any document VectorCraft opens (see document.formats), for the library panel (Window → Swatch Libraries lists it until the app quits) → {library: id, name, count}",
always,
load
),
@@ -98,23 +98,24 @@ struct Extra<L> {
pub trait LibraryFile: Sized {
/// The extensions of its files (lower case).
const EXTS: &'static [&'static str];
/// Read a library file whose name without the extension is `stem` (an unnamed library's name).
fn read(text: &str, stem: &str) -> std::result::Result<Self, String>;
/// Read the bytes of a library file whose name without the extension is `stem` (an unnamed
/// library's name).
fn read(bytes: &[u8], stem: &str) -> std::result::Result<Self, String>;
fn name(&self) -> &str;
}
impl LibraryFile for SwatchLibrary {
const EXTS: &'static [&'static str] = LIBRARY_EXTS;
fn read(text: &str, stem: &str) -> std::result::Result<Self, String> {
palette_io::read(text, stem)
fn read(bytes: &[u8], stem: &str) -> std::result::Result<Self, String> {
palette_io::read_bytes(bytes, stem)
}
fn name(&self) -> &str {
&self.name
}
}
/// The extensions of library files [`palette_io::read`] reads.
pub const LIBRARY_EXTS: &[&str] = &["vcswatches", "gpl"];
/// The extensions of library files [`palette_io::read_bytes`] reads.
pub const LIBRARY_EXTS: &[&str] = &["vcswatches", "gpl", "ase"];
impl<L: LibraryFile> Libraries<L> {
pub fn user_dir(&self) -> Option<&str> {
@@ -133,7 +134,7 @@ impl<L: LibraryFile> Libraries<L> {
let Some(dir) = self.user_dir.clone() else { return };
for path in library_files(&dir, L::EXTS) {
let file = file_name(&path);
let Some(lib) = read_file(&path).ok().and_then(|b| L::read(&String::from_utf8_lossy(&b), stem(&file)).ok()) else { continue };
let Some(lib) = read_file(&path).ok().and_then(|b| L::read(&b, stem(&file)).ok()) else { continue };
let info = LibraryInfo { id: format!("user/{file}"), name: lib.name().to_string(), category: "user" };
self.extra.push(Extra { info, path: Some(path), lib: Arc::new(lib) });
}
@@ -275,11 +276,18 @@ pub const DOCUMENT_SWATCHES: &str = "document";
/// [`DOCUMENT_SWATCHES`], of the active document's swatches and colour groups. `None` when there is
/// no such library (or document).
pub fn limit_palette(s: &Session, key: &str) -> Option<Palette> {
Some(Palette::new(library_colors(s, key)?))
}
/// The solid colours of library `key` (an id or name) or, for [`DOCUMENT_SWATCHES`], of the
/// active document's swatches and colour groups. `None` when there is no such library (or
/// document).
pub fn library_colors(s: &Session, key: &str) -> Option<Vec<Color>> {
let colors = |sw: &Swatch| sw.paint.color();
if key == DOCUMENT_SWATCHES {
return Some(Palette::new(s.active()?.doc.swatches_iter().filter_map(colors)));
return Some(s.active()?.doc.swatches_iter().filter_map(colors).collect());
}
Some(Palette::new(library(s, key)?.1.iter().filter_map(colors)))
Some(library(s, key)?.1.iter().filter_map(colors).collect())
}
/// The `limitTo` parameter's palette ([`limit_palette`]); `None` without one (or for "").
@@ -351,12 +359,14 @@ fn load(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "swatch.library.load";
let (info, lib) = s.swatch_libraries.load(p, C, |bytes, file| {
// A library file, or a document whose swatches become the library.
match std::str::from_utf8(bytes).ok().filter(|t| palette_io::sniff(t)) {
Some(t) => palette_io::read(t, stem(file)).map_err(|e| bad(C, e)),
None => {
let doc = super::fileio::load(file, bytes).map_err(|e| bad(C, e.to_string()))?.doc;
document_library(&doc, &[], stem(file).to_string(), C)
if palette_io::sniff_bytes(bytes) {
palette_io::read_bytes(bytes, stem(file)).map_err(|e| bad(C, e))
} else {
let loaded = super::fileio::load(file, bytes).map_err(|e| bad(C, e.to_string()))?;
if loaded.preview_only {
return Err(bad(C, "only this Affinity file's embedded preview could be read; use File › Open to see it with its warning"));
}
document_library(&loaded.doc, &[], stem(file).to_string(), C)
}
})?;
Ok(json!({"library": info.id, "name": info.name, "count": lib.len()}))
@@ -417,12 +427,17 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
} else {
s.edit("Add to Swatches", |d, _| {
let mut out = vec![];
// Each library group goes into one document group: the group of that name the document
// already had, or one made here.
let before = d.swatch_groups.len();
let mut placed: Vec<(&str, usize)> = vec![];
for (group, w) in fresh {
let name = d.free_swatch_name(&w.name);
let sw = Swatch { name: name.clone(), ..(*w).clone() };
match group {
Some(g) => {
let i = match d.swatch_groups.iter().position(|x| x.name == *g) {
let found = placed.iter().find(|(n, _)| *n == g).map(|&(_, i)| i);
let i = match found.or_else(|| d.swatch_groups.iter().take(before).position(|x| x.name == *g)) {
Some(i) => i,
None => {
let name = d.free_swatch_name(g);
@@ -430,6 +445,9 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
d.swatch_groups.len() - 1
}
};
if found.is_none() {
placed.push((g, i));
}
d.swatch_groups[i].swatches.push(sw);
}
None => d.swatches.push(sw),
+11 -4
View File
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character",
[],
None,
"{id, start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"} style a character range (runs are split at the range ends) → {id, runs}",
"{id, start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\"} style a character range (runs are split at the range ends) → {id, runs}",
has_doc,
set_range_style
),
@@ -198,7 +198,7 @@ pub(crate) fn features_param(p: &Value, cmd: &str) -> Result<Option<Vec<String>>
Ok(Some(vectorcraft_text::OtFeatures::default().with_tags(tags).to_tags()))
}
/// `charAlign: "romanBaseline"|"emBoxTop"|"emBoxCenter"|"emBoxBottom"` (Character Alignment).
/// `charAlign: "romanBaseline"|"emBoxTop"|"emBoxCenter"|"emBoxBottom"|"icfTop"|"icfBottom"` (Character Alignment).
pub(crate) fn char_align_param(p: &Value, cmd: &str) -> Result<Option<vectorcraft_doc::CharAlign>> {
use vectorcraft_doc::CharAlign;
let Some(v) = p.get("charAlign").filter(|v| !v.is_null()) else { return Ok(None) };
@@ -207,7 +207,14 @@ pub(crate) fn char_align_param(p: &Value, cmd: &str) -> Result<Option<vectorcraf
Some("emBoxTop") => CharAlign::EmBoxTop,
Some("emBoxCenter") => CharAlign::EmBoxCenter,
Some("emBoxBottom") => CharAlign::EmBoxBottom,
_ => return Err(bad(cmd, "`charAlign` must be \"romanBaseline\", \"emBoxTop\", \"emBoxCenter\" or \"emBoxBottom\"")),
Some("icfTop") => CharAlign::IcfTop,
Some("icfBottom") => CharAlign::IcfBottom,
_ => {
return Err(bad(
cmd,
"`charAlign` must be \"romanBaseline\", \"emBoxTop\", \"emBoxCenter\", \"emBoxBottom\", \"icfTop\" or \"icfBottom\"",
));
}
}))
}
@@ -468,7 +475,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
vertical: p.get("vertical").and_then(Value::as_bool).unwrap_or(false),
kind,
xf: Affine::IDENTITY,
runs: vec![TextRun { text, style }],
runs: vec![TextRun { text, style, inline: None }],
para: super::create::new_type_para(),
paras: Vec::new(),
area: Default::default(),
+1 -1
View File
@@ -219,7 +219,7 @@ fn release(s: &mut Session, _: &Value) -> Result<Value> {
let first_para = paras.first().cloned().unwrap_or_default();
for id in &out {
if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) {
t.runs = vec![TextRun { text: String::new(), style: style.clone() }];
t.runs = vec![TextRun { text: String::new(), style: style.clone(), inline: None }];
t.set_all_paras(first_para.clone());
refresh_bounds(t);
}
+36 -4
View File
@@ -147,6 +147,8 @@ fn auto_height(t: &mut TextObject) -> bool {
let col_w = if cols > 1 { ((b.width() - t.area.gutter.max(0.0) * (cols - 1) as f64) / cols as f64).max(1.0) } else { b.width() };
with_size(&mut probe, col_w, tall);
probe.area.columns = 1;
// Measured from the top: aligned in the tall probe the lines would sit far below.
probe.area.vertical_align = vectorcraft_doc::VerticalAlign::Top;
let lay = vectorcraft_text::layout(db, &probe);
let inset = t.area.inset.max(0.0);
let Some(bottom) = lay.lines.iter().map(|l| l.baseline + l.descent).filter(|y| y.is_finite()).reduce(f64::max) else { return false };
@@ -187,6 +189,20 @@ fn auto_height(t: &mut TextObject) -> bool {
true
}
/// Recompute the layout bounds cache for the text among `ids` (and their descendants) that lacks
/// it. Pasted or imported type arrives without the cache (not serialized), so selection boxes, the
/// Transform panel and hit testing would fall back to [`TextObject::estimate_bounds`] (a rough
/// 0.55 em per character) until the text is edited — the box comes up short and alignment looks off.
pub(crate) fn refresh_bounds_of(d: &mut Document, ids: &[NodeId]) {
for id in text_ids(d, ids) {
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind)
&& t.cached_bounds.is_none()
{
refresh_bounds(t);
}
}
}
/// Text objects among `ids` and their descendants.
fn text_ids(d: &Document, ids: &[NodeId]) -> Vec<NodeId> {
let mut out = vec![];
@@ -216,7 +232,16 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result<Value> {
let NodeKind::Text(t) = &n.kind else { continue };
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let mut children = vec![];
for g in &lay.glyphs {
for (gi, g) in lay.glyphs.iter().enumerate() {
// Inline graphics become instances of their symbols, in place.
if let Some(ig) = lay.inlines.iter().find(|i| i.glyph == gi)
&& let Some(art) = t.runs.get(ig.run).and_then(|r| r.inline.as_ref())
{
let id = d.alloc_id();
let xf = t.xf * ig.xf * d.symbol_natural_xf(&art.symbol);
children.push(Arc::new(Node::new(id, NodeKind::SymbolInstance { symbol: art.symbol.clone(), xf })));
continue;
}
let path = PathData::from_bezpath(&g.outline).transformed(t.xf);
if path.is_empty() {
continue;
@@ -320,7 +345,7 @@ fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
let style = t.first_style();
// Every paragraph of the new text takes the first paragraph's attributes.
t.splice_paras(0, vectorcraft_text::edit::runs_len(&t.runs), &text);
t.runs = vec![vectorcraft_doc::TextRun { text: text.clone(), style }];
t.runs = vec![vectorcraft_doc::TextRun { text: text.clone(), style, inline: None }];
refresh_bounds(t);
}
Ok(())
@@ -629,7 +654,7 @@ mod area_tests {
NodeKind::Text(t) => t.para.clone(),
_ => panic!("text"),
};
// New type: Standard, as in Illustrator.
// New type: Standard.
assert_eq!(para(&s).burasagari, Burasagari::Standard);
s.execute("select.set", &json!({"ids": [id]})).unwrap();
assert!(s.execute("text.setFormat", &json!({"burasagari": "strong"})).is_err());
@@ -719,7 +744,8 @@ mod area_tests {
use vectorcraft_doc::LeadingModel;
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": "一\n二", "area": {"width": 200, "height": 100}})).unwrap()["id"]
// Exercise ink bounds with bundled glyphs; Japanese outlines require optional craft-fonts.
let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "A\nB", "area": {"width": 200, "height": 100}})).unwrap()["id"]
.as_u64()
.unwrap();
let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
@@ -766,6 +792,12 @@ mod area_tests {
s.execute("text.setRangeStyle", &json!({"id": id, "start": 3, "end": 6, "charAlign": "emBoxTop"})).unwrap();
let aligns: Vec<_> = runs(&s).iter().map(|r| (r.text.clone(), r.style.char_align)).collect();
assert_eq!(aligns, [("雅".to_string(), CharAlign::RomanBaseline), ("楽".to_string(), CharAlign::EmBoxTop)]);
// The ideographic character face's top and bottom.
for (key, want) in [("icfTop", CharAlign::IcfTop), ("icfBottom", CharAlign::IcfBottom)] {
s.execute("text.setFormat", &json!({"charAlign": key})).unwrap();
assert!(runs(&s).iter().all(|r| r.style.char_align == want));
assert_eq!(serde_json::to_value(&runs(&s)[0].style).unwrap()["charAlign"], key);
}
}
#[test]
+23 -6
View File
@@ -140,7 +140,8 @@ fn edit_runs<S: Default>(s: &mut Session, label: &str, ids: &[NodeId], f: impl F
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
let mut st = S::default();
let mut any = false;
for r in &mut t.runs {
// Inline graphics are art, not characters to rewrite.
for r in t.runs.iter_mut().filter(|r| r.inline.is_none()) {
let n = f(&r.text, &mut st);
if n != r.text {
r.text = n;
@@ -323,7 +324,18 @@ fn to_point(s: &mut Session, p: &Value) -> Result<Value> {
}
} else if let Some((ri, bi)) = locate(&t.runs, b) {
t.splice_paras(b, b, "\n");
t.runs[ri].text.insert(bi, '\n');
// Never inside an inline graphic's run: at the end of the run before it, or a
// run of its own.
match t.runs.get(ri) {
Some(r) if r.inline.is_some() => match ri.checked_sub(1).and_then(|p| t.runs.get_mut(p)) {
Some(prev) if prev.inline.is_none() => prev.text.push('\n'),
_ => {
let style = t.runs.get(ri).map(|r| r.style.clone()).unwrap_or_default();
t.runs.insert(ri, TextRun::new("\n", style));
}
},
_ => t.runs[ri].text.insert(bi, '\n'),
}
}
}
// The point origin sits where the alignment anchors the first line.
@@ -481,7 +493,7 @@ pub(crate) fn fill_with_placeholder(t: &mut TextObject) {
}
_ => PLACEHOLDER.split(". ").next().unwrap_or(PLACEHOLDER).to_string() + ".",
};
t.runs = vec![TextRun { text, style }];
t.runs = vec![TextRun { text, style, inline: None }];
refresh_bounds(t);
}
@@ -531,8 +543,13 @@ fn insert_char(s: &mut Session, p: &Value) -> Result<Value> {
for id in &ids {
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
match t.runs.last_mut() {
Some(r) => r.text.push_str(&text),
None => t.runs.push(TextRun { text: text.clone(), style: CharStyle::default() }),
Some(r) if r.inline.is_none() => r.text.push_str(&text),
// After an inline graphic: a run of its own, in its style.
Some(r) => {
let style = r.style.clone();
t.runs.push(TextRun::new(text.clone(), style));
}
None => t.runs.push(TextRun { text: text.clone(), style: CharStyle::default(), inline: None }),
}
refresh_bounds(t);
}
@@ -647,7 +664,7 @@ fn find_next(s: &mut Session, p: &Value) -> Result<Value> {
fn strip_formatting(n: &mut vectorcraft_doc::Node) {
if let NodeKind::Text(t) = &mut n.kind {
let text = t.plain_text();
t.runs = vec![TextRun { text, style: CharStyle::default() }];
t.runs = vec![TextRun { text, style: CharStyle::default(), inline: None }];
t.set_all_paras(Default::default());
refresh_bounds(t);
}
+73 -1
View File
@@ -153,6 +153,8 @@ pub struct DocState {
pub recovery: Option<cmd::recovery::RecoveryCopy>,
/// View → Show Print Tiling, per document (view state: not saved, not undoable).
pub print_tiling: bool,
/// The rows open in the Layers panel (view state: not undoable; native files keep it).
pub layers_open: OpenRows,
/// 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>,
@@ -160,11 +162,61 @@ pub struct DocState {
static NEXT_DOC_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
/// The rows open in the Layers panel: the layers, sublayers and groups that show what they hold.
/// A document opens with the ones it was saved with ([`Document::layers_open`]), else with only
/// its top-level layers open.
#[derive(Clone, Debug, Default)]
pub struct OpenRows {
ids: std::collections::HashSet<NodeId>,
/// Counts the changes, so views can keep what they work out from the open rows.
generation: u64,
}
impl OpenRows {
/// The rows `saved` in a file, else `doc`'s top-level layers.
fn new(doc: &Document, saved: Option<Vec<NodeId>>) -> Self {
let ids = match saved {
Some(ids) => ids.into_iter().collect(),
None => doc.layers.iter().map(|l| l.id).collect(),
};
Self { ids, generation: 0 }
}
pub fn contains(&self, id: NodeId) -> bool {
self.ids.contains(&id)
}
/// Open or close row `id`.
pub fn set(&mut self, id: NodeId, open: bool) {
let changed = if open { self.ids.insert(id) } else { self.ids.remove(&id) };
self.generation += u64::from(changed);
}
/// Changes so far: the same number means the same open rows.
pub fn generation(&self) -> u64 {
self.generation
}
/// What a native file keeps of the open rows: those of `doc` that hold others, in id order;
/// `None` when they are the default (only the top-level layers).
pub fn saved(&self, doc: &Document) -> Option<Vec<NodeId>> {
let mut ids = vec![];
doc.walk(|n| {
if n.children().is_some() && self.ids.contains(&n.id) {
ids.push(n.id);
}
});
ids.sort_unstable();
let mut layers: Vec<NodeId> = doc.layers.iter().map(|l| l.id).collect();
layers.sort_unstable();
(ids != layers).then_some(ids)
}
}
impl DocState {
pub fn new(mut doc: Document, path: Option<String>) -> Self {
let active_layer = doc.default_layer();
// The saved view lives here while the document is open (saves write it back).
// The saved view and open Layers rows live here while the document is open (saves write
// them back).
let view = doc.last_view.take();
let saved_open = doc.layers_open.take();
let layers_open = OpenRows::new(&doc, saved_open);
let doc = Arc::new(doc);
Self {
saved_doc: doc.clone(),
@@ -190,6 +242,7 @@ impl DocState {
recovered: false,
recovery: None,
print_tiling: false,
layers_open,
last_perspective: None,
}
}
@@ -427,6 +480,9 @@ pub struct Prefs {
// Hyphenation
pub hyphenation_language: String,
pub hyphenation_exceptions: String,
/// Type › Options › Additional Fonts Folder: a folder (read with its subfolders) whose fonts
/// are listed and used as if installed (#683); empty for none.
pub fonts_folder: String,
// Performance & Storage (Plug-ins & Scratch Disks)
pub plugins_folder: String,
pub scratch_primary: String,
@@ -435,6 +491,9 @@ pub struct Prefs {
pub ui_brightness: String,
pub canvas_color: String,
pub auto_collapse_icon_panels: bool,
/// User Interface › Show Tool Group Labels: the toolbar's group names (Select, Shapes, Draw…);
/// off, a faint dash separates the groups instead (#663).
pub tool_group_labels: bool,
pub open_documents_as_tabs: bool,
pub large_tabs: bool,
pub ui_scaling: f64,
@@ -643,12 +702,14 @@ impl Default for Prefs {
slice_line_color: s("#ff3f3f"),
hyphenation_language: s("English: USA"),
hyphenation_exceptions: String::new(),
fonts_folder: String::new(),
plugins_folder: String::new(),
scratch_primary: s("Startup"),
scratch_secondary: s("None"),
ui_brightness: s("mediumDark"),
canvas_color: s("matchUi"),
auto_collapse_icon_panels: false,
tool_group_labels: true,
open_documents_as_tabs: true,
large_tabs: false,
ui_scaling: 1.0,
@@ -908,6 +969,8 @@ impl Session {
/// Text layout bounds are a cache (not saved): compute them for a document just read, or
/// selection boxes and hit testing would use the rough estimate until each text is edited.
fn refresh_text_bounds(doc: &mut Document) {
// Inline graphics in text take their size from their symbols' art (not saved either).
doc.resolve_inline_art();
let mut texts = vec![];
doc.walk(|n| {
if matches!(n.kind, NodeKind::Text(_)) {
@@ -954,6 +1017,7 @@ impl Session {
st.save_options = old.save_options.clone();
st.converted = old.converted;
st.view = old.view.clone();
st.layers_open = old.layers_open.clone();
let old = std::mem::replace(&mut self.docs[index], st);
if let Some(stash) = &mut self.batch_stash {
stash.push(old);
@@ -1103,6 +1167,8 @@ impl Session {
Ok(v) => match cmd::shaper::refresh(&before, Arc::make_mut(&mut st.doc)) {
Err(e) => Err(e),
Ok(()) => {
// Inline graphics in text follow their symbols.
cmd::inline::refresh(&before, Arc::make_mut(&mut st.doc));
// Text Wrap: area type follows its wrap objects; then threads re-flow.
cmd::textwrap::refresh(Arc::make_mut(&mut st.doc));
// Opacity-mask editing: the mask follows its art on the editing layer.
@@ -1388,6 +1454,8 @@ mod tests_gradpanel;
#[cfg(test)]
mod tests_halftone;
#[cfg(test)]
mod tests_inline;
#[cfg(test)]
mod tests_journal;
#[cfg(test)]
mod tests_knockout;
@@ -1484,6 +1552,8 @@ mod tests_proxyitems;
#[cfg(test)]
mod tests_puppetwarp;
#[cfg(test)]
mod tests_rasterfilters;
#[cfg(test)]
mod tests_rastersettings;
#[cfg(test)]
mod tests_recolor;
@@ -1526,6 +1596,8 @@ mod tests_swatchlib;
#[cfg(test)]
mod tests_targeting;
#[cfg(test)]
mod tests_textcombos;
#[cfg(test)]
mod tests_textedit;
#[cfg(test)]
mod tests_textimport;
+156 -1
View File
@@ -4,7 +4,7 @@ use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{ImageBlob, ImageObject, Node, NodeKind};
use vectorcraft_doc::{AppearanceItem, ImageBlob, ImageObject, LineCap, LineJoin, Node, NodeKind};
use vectorcraft_geom::{Affine, FillRule, PathData, Rect};
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
@@ -669,6 +669,82 @@ fn image_trace_make_and_expand_and_color_presets() {
assert!(s.execute("imageTrace.make", &json!({"params": {"mode": "rainbow"}})).is_err());
}
#[test]
fn image_trace_palettes_and_swatch_libraries() {
let mut s = session();
let quads = vectorcraft_trace::Raster::from_fn(60, 60, |x, y| match (x < 30, y < 30) {
(true, true) => [230, 20, 20, 255],
(false, true) => [20, 200, 30, 255],
(true, false) => [20, 30, 220, 255],
(false, false) => [250, 230, 10, 255],
});
add_image(&mut s, &quads, Affine::IDENTITY);
let fills = |s: &Session, id: &Value| -> Vec<Color> {
traced_paths(&node(s, NodeId(id.as_u64().unwrap()))).iter().filter_map(|n| n.appearance.fill_paint().color()).collect()
};
// Document Library: every fill is one of the document's swatch colours, as it is.
let r = s.execute("imageTrace.make", &json!({"preset": "6 Colors", "params": {"palette": "documentLibrary"}})).unwrap();
let doc = crate::cmd::swatchlib::library_colors(&s, "document").unwrap();
let got = fills(&s, &r["id"]);
assert!(!got.is_empty() && got.iter().all(|c| doc.contains(c)), "{got:?}");
let t = node(&s, NodeId(r["id"].as_u64().unwrap())).trace.unwrap();
assert_eq!((t["params"]["palette"].as_str(), t["params"]["library"].as_str()), (Some("documentLibrary"), Some("document")));
assert!(t["params"].get("swatches").is_none(), "looked up again at every trace");
// A swatch library by id: the web-safe colours.
let r = s
.execute(
"imageTrace.make",
&json!({"id": r["id"], "preset": "6 Colors", "params": {"palette": "documentLibrary", "library": "web-safe-216", "colors": 3}}),
)
.unwrap();
let got = fills(&s, &r["id"]);
assert!((1..=3).contains(&got.len()), "{got:?}");
assert!(got.iter().all(|c| c.to_rgba8(1.0)[..3].iter().all(|v| v % 51 == 0)), "{got:?}");
// Full Tone and Automatic keep the four flat colours.
let mut id = r["id"].clone();
for palette in ["fullTone", "automatic"] {
let r = s.execute("imageTrace.make", &json!({"id": id, "preset": "6 Colors", "params": {"palette": palette, "colorDetail": 50}})).unwrap();
assert_eq!(r["colors"], 4, "{palette}");
id = r["id"].clone();
}
let mut bad = |p: Value| s.execute("imageTrace.make", &json!({"preset": "6 Colors", "params": p})).is_err();
assert!(bad(json!({"palette": "documentLibrary", "library": "No Such Library"})));
assert!(bad(json!({"palette": "rainbow"})));
}
#[test]
fn image_trace_create_strokes_makes_stroked_centre_lines() {
let mut s = session();
// A 3 px wide horizontal line from x = 10 to 90 at y = 50, placed at 2× from (100, 100).
let r =
vectorcraft_trace::Raster::from_fn(100, 100, |x, y| if (10..90).contains(&x) && (49..52).contains(&y) { [0, 0, 0, 255] } else { [255; 4] });
add_image(&mut s, &r, Affine::translate((100.0, 100.0)) * Affine::scale(2.0));
let res = s.execute("imageTrace.make", &json!({"params": {"ignoreWhite": true, "strokes": true, "strokeWidth": 5}})).unwrap();
assert_eq!(res["paths"], 1);
let g = NodeId(res["id"].as_u64().unwrap());
let paths = traced_paths(&node(&s, g));
let line = &paths[0];
assert!(line.appearance.fill_paint().color().is_none(), "not filled");
let st = line.appearance.items.iter().find_map(|i| if let AppearanceItem::Stroke(st) = i { Some(st) } else { None }).expect("stroked");
assert!((st.width - 6.0).abs() < 1.0, "3 px at 2× is about 6 pt: {}", st.width);
assert_eq!((st.cap, st.join), (LineCap::Round, LineJoin::Round));
assert_eq!(st.paint, Paint::solid(Color::rgb8(0, 0, 0)));
let sp = &line.path_data().unwrap().subpaths[0];
assert!(!sp.closed);
let (a, b) = (sp.anchors[0].p, sp.anchors[sp.anchors.len() - 1].p);
assert!((a.y - 201.0).abs() < 1.5 && (b.y - 201.0).abs() < 1.5 && (a.x - b.x).abs() > 140.0, "{a:?} … {b:?}");
let t = node(&s, g).trace.unwrap();
assert_eq!((t["params"]["strokes"].as_bool(), t["params"]["strokeWidth"].as_f64()), (Some(true), Some(5.0)));
// Expand keeps the stroked line.
s.execute("imageTrace.expand", &json!({})).unwrap();
assert!(node(&s, g).children().unwrap()[0].appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(_))));
// Create needs Fills or Strokes, and a stroke width.
s.execute("edit.undo", &json!({})).unwrap();
for p in [json!({"fills": false, "strokes": false}), json!({"strokes": true, "strokeWidth": 0}), json!({"strokeWidth": -3})] {
assert!(s.execute("imageTrace.make", &json!({"id": g.0, "params": p})).is_err(), "{p}");
}
}
#[test]
fn image_trace_presets_query_and_errors() {
let mut s = session();
@@ -705,6 +781,85 @@ fn image_trace_object_remembers_its_settings() {
assert!(st.doc.node(id).unwrap().trace.is_none());
}
/// A black disc (r = 30 px) on light blue, 100 × 100 px, placed at 2× from (50, 50): traced in
/// Black and White with Ignore White, the blue drops out and one disc (r = 60 pt at (150, 150))
/// remains.
fn disc_on_blue(s: &mut Session) -> NodeId {
let r = vectorcraft_trace::Raster::from_fn(100, 100, |x, y| {
let (dx, dy) = (x as f64 + 0.5 - 50.0, y as f64 + 0.5 - 50.0);
if dx * dx + dy * dy <= 900.0 { [0, 0, 0, 255] } else { [170, 210, 255, 255] }
});
add_image(s, &r, Affine::translate((50.0, 50.0)) * Affine::scale(2.0));
let r = s.execute("imageTrace.make", &json!({"params": {"ignoreWhite": true}})).unwrap();
assert_eq!(r["paths"], 1);
NodeId(r["id"].as_u64().unwrap())
}
/// The document rendered as the canvas draws it (`trace_views`) or as exports do.
fn render_doc(s: &Session, trace_views: bool) -> vectorcraft_render::Rendered {
let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), trace_views, ..Default::default() };
vectorcraft_render::Renderer::new().render(&s.doc().unwrap().doc, 300, 300, Affine::IDENTITY, &opts)
}
#[test]
fn image_trace_view_is_stored_kept_and_validated() {
let mut s = session();
let g = disc_on_blue(&mut s);
assert_eq!(node(&s, g).trace.unwrap()["view"], "tracingResult");
let r = s.execute("imageTrace.setView", &json!({"view": "outlinesWithSourceImage"})).unwrap();
assert_eq!((ids(&r), r["view"].as_str()), (vec![g], Some("outlinesWithSourceImage")));
assert_eq!(node(&s, g).trace_view(), vectorcraft_doc::TraceView::OutlinesWithSource);
// Setting the view it has already adds no undo step.
let steps = s.doc().unwrap().history.undo.len();
s.execute("imageTrace.setView", &json!({"id": g.0, "view": "outlinesWithSourceImage"})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), steps);
// Tracing again keeps it, unless another one is asked for; it survives save and open.
let g = NodeId(s.execute("imageTrace.make", &json!({"id": g.0, "params": {"ignoreWhite": true, "noise": 30}})).unwrap()["id"].as_u64().unwrap());
assert_eq!(node(&s, g).trace_view(), vectorcraft_doc::TraceView::OutlinesWithSource);
let g = NodeId(s.execute("imageTrace.make", &json!({"id": g.0, "view": "SourceImage"})).unwrap()["id"].as_u64().unwrap());
let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
assert_eq!(back.node(g).unwrap().trace_view(), vectorcraft_doc::TraceView::Source);
// Bad views and other objects are refused.
assert!(s.execute("imageTrace.setView", &json!({"view": "sepia"})).is_err());
assert!(s.execute("imageTrace.setView", &json!({})).is_err());
assert!(s.execute("imageTrace.make", &json!({"view": 3})).is_err());
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
assert!(s.execute("imageTrace.setView", &json!({"id": a.0, "view": "outlines"})).is_err());
}
#[test]
fn image_trace_views_draw_on_screen_only_and_expand_to_the_result() {
let mut s = session();
let g = disc_on_blue(&mut s);
let dark = |p: [u8; 4]| p[0] < 60 && p[1] < 60 && p[2] < 60;
let white = |p: [u8; 4]| p[0] > 245 && p[1] > 245 && p[2] > 245;
let blue = |p: [u8; 4]| p[0] < 200 && p[2] > 245;
let (centre, beside) = ((150, 150), (70, 70));
let at = |img: &vectorcraft_render::Rendered, (x, y): (u32, u32)| img.pixel(x, y);
// The outline is a 1 px line where the disc's right side is (x = 210, anti-aliased).
let outlined = |img: &vectorcraft_render::Rendered| (205..=215).any(|x| img.pixel(x, 150)[0] < 128);
let mut expanded = None;
for view in vectorcraft_doc::TraceView::ALL {
s.execute("imageTrace.setView", &json!({"id": g.0, "view": view.id()})).unwrap();
let screen = render_doc(&s, true);
let (c, b) = (at(&screen, centre), at(&screen, beside));
assert_eq!(dark(c), view.shows_result() || view.shows_source(), "{view:?}: the disc's middle {c:?}");
assert_eq!(blue(b), view.shows_source(), "{view:?}: the image's background {b:?}");
assert!(view.shows_source() || white(b), "{view:?}: nothing beside the disc {b:?}");
if view == vectorcraft_doc::TraceView::Outlines {
assert!(outlined(&screen), "{view:?}: the disc's outline");
}
// Exports draw the tracing result whatever the view.
let export = render_doc(&s, false);
assert!(dark(at(&export, centre)) && white(at(&export, beside)), "{view:?}: export");
// Expand keeps the traced shapes, as traced, whatever the view.
s.execute("imageTrace.expand", &json!({"id": g.0})).unwrap();
let shapes: Vec<_> = node(&s, g).children().unwrap().iter().map(|c| (c.path_data().cloned(), c.appearance.clone())).collect();
assert_eq!(*expanded.get_or_insert_with(|| shapes.clone()), shapes, "{view:?}: expanded");
s.execute("edit.undo", &json!({})).unwrap();
}
}
/// #569: merging the regions of a rosette of unfilled ellipses (each off the centre, petals
/// overlapping) merges every region the drags touch into one shape whose outline is only the
/// merged area's border: no edge between merged regions stays in, as a stray line or a spur.
+20
View File
@@ -551,6 +551,26 @@ fn paste_on_all_artboards_keeps_the_offset_to_the_source_artboard() {
}
}
/// Paste in Place, in Front and in Back onto the active artboard (#693): where the objects were on
/// the artboard they were copied from, on the one named; without it, where they were.
#[test]
fn in_place_pastes_go_onto_the_artboard_named() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 3})).unwrap();
let boards: Vec<Rect> = doc(&s).artboards.iter().map(|a| a.rect).collect();
let r = rect(&mut s, boards[0].x0 + 10.0, boards[0].y0 + 15.0, 20.0, 20.0);
copy(&mut s, &[r]);
for cmd in ["edit.pasteInPlace", "edit.pasteInFront", "edit.pasteInBack"] {
let ids = ids_of(&s.execute(cmd, &json!({"artboard": 2})).unwrap());
assert_eq!(doc(&s).bounds_of(&ids, false).unwrap().origin(), Point::new(boards[2].x0 + 10.0, boards[2].y0 + 15.0), "{cmd}");
let ids = ids_of(&s.execute(cmd, &json!({})).unwrap());
assert_eq!(doc(&s).bounds_of(&ids, false).unwrap().origin(), Point::new(boards[0].x0 + 10.0, boards[0].y0 + 15.0), "{cmd} without one");
// An artboard that isn't there: where they were.
let ids = ids_of(&s.execute(cmd, &json!({"artboard": 9})).unwrap());
assert_eq!(doc(&s).bounds_of(&ids, false).unwrap().origin(), Point::new(boards[0].x0 + 10.0, boards[0].y0 + 15.0), "{cmd} on no artboard");
}
}
#[test]
fn paste_remembers_layers_restores_the_source_layers() {
let mut s = session();
+207
View File
@@ -0,0 +1,207 @@
//! Inline graphics in text: `text.insertInline`, editing over them, undo, following their symbol.
use serde_json::json;
use vectorcraft_doc::text::INLINE_CHAR;
use vectorcraft_doc::{NodeId, NodeKind, TextObject};
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
/// A symbol `name` made of a circle `d` points across (drawn in code), its instance deleted.
fn circle_symbol(s: &mut Session, name: &str, d: f64) {
s.execute("shape.ellipse", &json!({"x": 600, "y": 500, "width": d, "height": d})).unwrap();
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
s.execute("symbol.new", &json!({"name": name})).unwrap();
s.execute("edit.clear", &json!({})).unwrap();
}
fn text(s: &mut Session, t: &str) -> NodeId {
let r = s.execute("text.create", &json!({"x": 100, "y": 100, "text": t, "size": 20})).unwrap();
NodeId(r["id"].as_u64().unwrap())
}
fn obj(s: &Session, id: NodeId) -> TextObject {
match &s.doc().unwrap().doc.node(id).unwrap().kind {
NodeKind::Text(t) => (**t).clone(),
_ => panic!("not text"),
}
}
fn inline_count(t: &TextObject) -> usize {
t.runs.iter().filter(|r| r.inline.is_some()).count()
}
#[test]
fn insert_inline_places_a_symbol_as_one_character() {
let mut s = session();
circle_symbol(&mut s, "Tap", 10.0);
let id = text(&mut s, "Tap: add");
let before = obj(&s, id).bounds().unwrap();
let r = s.execute("text.insertInline", &json!({"id": id.0, "at": 0, "symbol": "Tap", "scale": 1.5, "shift": 1})).unwrap();
assert_eq!(r, json!({"id": id.0, "caret": INLINE_CHAR.len_utf8()}));
let t = obj(&s, id);
assert_eq!(t.plain_text(), format!("{INLINE_CHAR}Tap: add"));
let art = t.runs[0].inline.as_ref().unwrap();
assert_eq!((art.symbol.as_str(), art.scale, art.baseline_shift), ("Tap", 1.5, 1.0));
// Resolved from the symbol (a 10 pt circle), and the text grew by the art (30 pt wide).
let b = art.bounds.unwrap();
assert!((b.width() - 10.0).abs() < 0.5 && (b.height() - 10.0).abs() < 0.5, "{b:?}");
let after = t.bounds().unwrap();
assert!((after.width() - before.width() - 30.0).abs() < 1.0, "{before:?} → {after:?}");
// It takes the style of the text it sits in.
assert_eq!(t.runs[0].style.size, 20.0);
// text.getRange reports it.
let g = s.execute("text.getRange", &json!({"id": id.0, "start": 0, "end": 3})).unwrap();
assert_eq!(g["runs"][0]["inline"], json!({"symbol": "Tap", "scale": 1.5, "baseline_shift": 1.0}));
assert_eq!(g["text"], json!(INLINE_CHAR.to_string()));
// One undo step.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(inline_count(&obj(&s, id)), 0);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(inline_count(&obj(&s, id)), 1);
// Deleting any part of its character deletes it whole.
s.execute("text.editRange", &json!({"id": id.0, "start": 1, "end": 3})).unwrap();
let t = obj(&s, id);
assert_eq!(t.plain_text(), "Tap: add");
assert_eq!(inline_count(&t), 0);
}
#[test]
fn insert_inline_rejects_junk() {
let mut s = session();
circle_symbol(&mut s, "Dot", 8.0);
let id = text(&mut s, "abc");
let rect = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
for p in [
json!({"id": id.0, "symbol": "Nope"}),
json!({"id": id.0, "symbol": 7}),
json!({"id": id.0, "symbol": "Dot", "scale": 0}),
json!({"id": id.0, "symbol": "Dot", "scale": -1}),
json!({"id": id.0, "symbol": "Dot", "scale": 1e9}),
json!({"id": id.0, "symbol": "Dot", "shift": "up"}),
json!({"id": id.0, "symbol": "Dot", "at": -3}),
json!({"id": "x", "symbol": "Dot"}),
json!({"id": rect["id"], "symbol": "Dot"}),
json!({"id": 999999, "symbol": "Dot"}),
json!({"symbol": "Dot"}),
] {
assert!(s.execute("text.insertInline", &p).is_err(), "{p}");
}
assert_eq!(inline_count(&obj(&s, id)), 0);
// Offsets past the end are clamped; mid-character offsets land on a boundary.
let r = s.execute("text.insertInline", &json!({"id": id.0, "at": 99, "symbol": "Dot"})).unwrap();
assert_eq!(r["caret"], json!(3 + INLINE_CHAR.len_utf8()));
s.execute("text.insertInline", &json!({"id": id.0, "at": 4, "symbol": "Dot"})).unwrap();
let t = obj(&s, id);
assert_eq!(t.plain_text(), format!("abc{INLINE_CHAR}{INLINE_CHAR}"));
assert_eq!(inline_count(&t), 2);
// A document without symbols has nothing to insert.
let mut s = session();
let id = text(&mut s, "abc");
assert!(s.execute("text.insertInline", &json!({"id": id.0})).is_err());
}
#[test]
fn type_tool_inserts_at_its_caret_and_deletes_the_graphic_whole() {
let mut s = session();
circle_symbol(&mut s, "Mana", 10.0);
s.execute("symbol.setCurrent", &json!({"name": "Mana"})).unwrap();
let v = ViewInfo::default();
s.select_tool("type", v).unwrap();
for k in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(k, 50.0, 300.0), v).unwrap();
}
let id = s.doc().unwrap().selection.objects[0];
s.tool_text("Pay ", v).unwrap();
// The menu item: no params, inside a typing session.
let r = s.execute("text.insertInline", &json!({})).unwrap();
assert_eq!(r["id"], json!(id.0));
assert_eq!(s.tool_options()["caret"], json!(4 + INLINE_CHAR.len_utf8()));
s.tool_text(" now", v).unwrap();
let t = obj(&s, id);
assert_eq!(t.plain_text(), format!("Pay {INLINE_CHAR} now"));
assert_eq!(inline_count(&t), 1);
assert_eq!(t.runs.iter().find(|r| r.inline.is_some()).unwrap().inline.as_ref().unwrap().symbol, "Mana");
// Back over " now", then the graphic: one Backspace removes it whole.
for _ in 0..5 {
s.tool_key(ToolKey::Backspace, Mods::default(), v).unwrap();
}
let t = obj(&s, id);
assert_eq!(t.plain_text(), "Pay ");
assert_eq!(inline_count(&t), 0);
// Undo brings it back.
s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(inline_count(&obj(&s, id)), 1);
}
#[test]
fn inline_graphics_follow_their_symbol() {
let mut s = session();
circle_symbol(&mut s, "Dot", 10.0);
let id = text(&mut s, "ab");
s.execute("text.insertInline", &json!({"id": id.0, "at": 1, "symbol": "Dot", "scale": 1})).unwrap();
let w0 = obj(&s, id).bounds().unwrap().width();
// Redefine the symbol as a 2:1 ellipse: the graphic (20 pt tall) widens to 40 pt.
let e = s.execute("shape.ellipse", &json!({"x": 300, "y": 300, "width": 40, "height": 20})).unwrap();
s.execute("select.set", &json!({"ids": [e["id"]]})).unwrap();
s.execute("symbol.update", &json!({"name": "Dot"})).unwrap();
let t = obj(&s, id);
let b = t.runs[1].inline.as_ref().unwrap().bounds.unwrap();
assert!((b.width() / b.height() - 2.0).abs() < 0.1, "{b:?}");
let w1 = t.bounds().unwrap().width();
assert!((w1 - w0 - 20.0).abs() < 1.5, "{w0} → {w1}");
// Deleting the symbol leaves an empty slot (nothing drawn), and doesn't crash rendering.
s.execute("symbol.delete", &json!({"name": "Dot"})).unwrap();
let t = obj(&s, id);
assert!(t.runs[1].inline.as_ref().unwrap().bounds.is_none());
let _ = s.execute("document.serialize", &json!({"format": "png"})).unwrap();
let _ = s.execute("document.serialize", &json!({"format": "svg"})).unwrap();
let _ = s.execute("document.serialize", &json!({"format": "pdf"})).unwrap();
}
#[test]
fn saved_documents_keep_inline_graphics() {
let mut s = session();
circle_symbol(&mut s, "Dot", 10.0);
let id = text(&mut s, "ab");
s.execute("text.insertInline", &json!({"id": id.0, "at": 1, "symbol": "Dot", "scale": 2})).unwrap();
let doc = (*s.doc().unwrap().doc).clone();
let bytes = vectorcraft_format::save(&doc, false);
assert!(String::from_utf8_lossy(&bytes).contains("\"inline\""));
let mut back = vectorcraft_format::load(&bytes).unwrap();
// Bounds aren't saved: opening resolves them.
let mut s2 = Session::new();
back.title = "copy".into();
s2.add_document(back, None);
let t = obj(&s2, id);
let art = t.runs[1].inline.as_ref().unwrap();
assert_eq!((art.symbol.as_str(), art.scale), ("Dot", 2.0));
assert!(art.bounds.is_some());
assert_eq!(t.cached_bounds, obj(&s, id).cached_bounds);
}
#[test]
fn create_outlines_turns_inline_graphics_into_symbol_instances() {
let mut s = session();
circle_symbol(&mut s, "Dot", 10.0);
let id = text(&mut s, "a b");
s.execute("text.insertInline", &json!({"id": id.0, "at": 2, "symbol": "Dot"})).unwrap();
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
let r = s.execute("type.createOutlines", &json!({})).unwrap();
let g = NodeId(r["ids"][0].as_u64().unwrap());
let d = &s.doc().unwrap().doc;
let kids = d.node(g).unwrap().children().unwrap();
let inst = kids.iter().filter(|c| matches!(&c.kind, NodeKind::SymbolInstance { symbol, .. } if symbol == "Dot")).count();
assert_eq!(inst, 1);
// Between the outlines of `a` and `b`.
let pos = kids.iter().position(|c| matches!(c.kind, NodeKind::SymbolInstance { .. })).unwrap();
assert!(pos > 0 && pos + 1 < kids.len(), "{pos} of {}", kids.len());
}
+32
View File
@@ -1159,3 +1159,35 @@ fn saved_selection_names_are_trimmed_and_capped() {
let list = s.execute("select.savedList", &json!({})).unwrap();
assert_eq!(list[0].as_str().map(|n| n.chars().count()), Some(vectorcraft_doc::SavedSelection::MAX_NAME));
}
#[test]
fn crop_image_trim_cuts_to_the_opaque_pixels_as_they_are() {
// 100 × 80 px, transparent but for a block at (20, 10)–(50, 40) px; placed at 2 pt a pixel.
let mut px = image::RgbaImage::new(100, 80);
for y in 10..40 {
for x in 20..50 {
px.put_pixel(x, y, image::Rgba([x as u8, y as u8, 7, if x == 20 { 1 } else { 255 }]));
}
}
let mut png = Vec::new();
px.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let mut s = Session::new();
s.execute("document.open", &json!({"name": "art.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap();
s.execute("select.all", &json!({})).unwrap();
let img = selected(&s)[0];
s.execute("object.scale", &json!({"sx": 200, "origin": [0, 0]})).unwrap();
let r = s.execute("object.cropImage", &json!({"trim": true})).unwrap();
assert_eq!((r["width"].as_u64().unwrap(), r["height"].as_u64().unwrap()), (30, 30), "{r}");
// Placed where those pixels were: (40, 20) pt, 60 × 60 pt at 2 pt a pixel.
let b = bounds(&s, img);
assert!(close(b.x0, 40.0) && close(b.y0, 20.0) && close(b.width(), 60.0) && close(b.height(), 60.0), "{b:?}");
// Pixel for pixel, a barely visible (alpha 1) edge included.
let NodeKind::Image(im) = &node(&s, img).kind else { panic!("an image") };
let out = image::load_from_memory(&s.doc().unwrap().doc.images[&im.key].bytes).unwrap().to_rgba8();
assert_eq!(out, image::imageops::crop_imm(&px, 20, 10, 30, 30).to_image());
assert_eq!(r["trimmed"], true);
// Nothing transparent left to cut: left as it is, and not an error (scripts trim every image).
let r = s.execute("object.cropImage", &json!({"trim": true})).unwrap();
assert_eq!((r["trimmed"].as_bool(), r["width"].as_u64()), (Some(false), Some(30)), "{r}");
assert_eq!(bounds(&s, img), b);
}
+60
View File
@@ -223,3 +223,63 @@ fn rearrange_keeps_its_grid() {
s.execute("artboard.rearrange", &json!({"columns": 2, "spacing": 5})).unwrap();
assert_eq!(boards(&s).iter().map(|r| (r.x0, r.y0)).collect::<Vec<_>>(), [(0.0, 0.0), (105.0, 0.0), (0.0, 55.0)]);
}
/// #681: every layout and order of Rearrange All Artboards, five 100 × 50 artboards 10 pt apart
/// with Columns (the rows of a grid by column) 2: each artboard lands in its (row, column) cell, the
/// art on it rides along, and it all undoes in one step.
#[test]
fn rearrange_lays_out_by_layout_and_order() {
type Cells = [(u32, u32); 5];
let cells: [(&str, &str, Cells); 8] = [
("gridByRow", "leftToRight", [(0, 0), (0, 1), (1, 0), (1, 1), (2, 0)]),
("gridByRow", "rightToLeft", [(0, 1), (0, 0), (1, 1), (1, 0), (2, 1)]),
("gridByColumn", "leftToRight", [(0, 0), (1, 0), (0, 1), (1, 1), (0, 2)]),
("gridByColumn", "rightToLeft", [(0, 2), (1, 2), (0, 1), (1, 1), (0, 0)]),
("row", "leftToRight", [(0, 0), (0, 1), (0, 2), (0, 3), (0, 4)]),
("row", "rightToLeft", [(0, 4), (0, 3), (0, 2), (0, 1), (0, 0)]),
("column", "leftToRight", [(0, 0), (1, 0), (2, 0), (3, 0), (4, 0)]),
("column", "rightToLeft", [(0, 0), (1, 0), (2, 0), (3, 0), (4, 0)]),
];
for (layout, order, want) in cells {
// One row, 20 pt apart; a 10 pt square 10 pt into each artboard.
let mut s = new_doc(json!({"width": 100, "height": 50, "artboards": 5}));
let art: Vec<u64> = boards(&s)
.iter()
.map(|r| {
s.execute("shape.rectangle", &json!({"x": r.x0 + 10.0, "y": r.y0 + 10.0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap()
})
.collect();
let before = boards(&s);
let undo = s.doc().unwrap().history.undo.len();
let p = json!({"layout": layout, "order": order, "columns": 2, "spacing": 10});
let r = s.execute("artboard.rearrange", &p).unwrap();
let (rows, cols) = want.iter().fold((0, 0), |(r, c), (wr, wc)| (r.max(wr + 1), c.max(wc + 1)));
assert_eq!(r, json!({"artboards": 5, "rows": rows, "columns": cols}), "{layout} {order}");
let got: Vec<(f64, f64)> = boards(&s).iter().map(|r| (r.x0, r.y0)).collect();
let cells: Vec<(f64, f64)> = want.iter().map(|&(r, c)| (f64::from(c) * 110.0, f64::from(r) * 60.0)).collect();
assert_eq!(got, cells, "{layout} {order}");
assert!(boards(&s).iter().all(|r| r.width() == 100.0 && r.height() == 50.0));
for (id, (x, y)) in art.iter().zip(&got) {
let b = s.doc().unwrap().doc.node(NodeId(*id)).unwrap().geometric_bounds().unwrap();
assert_eq!((b.x0, b.y0), (x + 10.0, y + 10.0), "{layout} {order}: the art moves with its artboard");
}
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(boards(&s), before);
}
}
/// `byColumn: true` is Grid by Column; without `moveArtwork` the art stays; bad values are errors.
#[test]
fn rearrange_aliases_and_rejects_bad_values() {
let mut s = new_doc(json!({"width": 100, "height": 50, "artboards": 3}));
let id = s.execute("shape.rectangle", &json!({"x": 130, "y": 10, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
s.execute("artboard.rearrange", &json!({"byColumn": true, "columns": 2, "spacing": 0, "moveArtwork": false})).unwrap();
assert_eq!(boards(&s).iter().map(|r| (r.x0, r.y0)).collect::<Vec<_>>(), [(0.0, 0.0), (0.0, 50.0), (100.0, 0.0)]);
assert_eq!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().geometric_bounds().unwrap().x0, 130.0, "the art stays");
let before = boards(&s);
for p in [json!({"layout": "diagonal"}), json!({"order": "upward"}), json!({"layout": 3}), json!({"order": true})] {
assert!(s.execute("artboard.rearrange", &p).is_err(), "{p}");
}
assert_eq!(boards(&s), before, "nothing moved");
}
+27
View File
@@ -282,6 +282,33 @@ fn snap_to_point_lands_a_dragged_selection_on_anchors() {
assert_eq!(drag(&mut s, ViewInfo { snap_to_point: false, ..v }), (276.5, 76.0), "View › Snap to Point off");
}
/// Smart Guides › Spacing Guides (#394): a square dragged 21 pt to the right of a row of two
/// squares 20 pt apart lands 20 pt from it, with the gaps marked; off, it stays at 21.
#[test]
fn spacing_guides_space_a_dragged_square_like_its_row() {
use vectorcraft_tools::PointerKind::{Down, Drag, Up};
let mut s = new_doc();
square(&mut s, 20.0, 300.0);
square(&mut s, 90.0, 300.0);
let m = square(&mut s, 200.0, 305.0);
let v = ViewInfo::default();
s.select_tool("selection", v).unwrap();
// `m` grabbed by its centre and dragged so its left edge is 21 pt from the second square.
let drag = |s: &mut Session| {
gesture(s, v, &[(Down, 225.0, 330.0), (Drag, 200.0, 330.0), (Drag, 186.0, 330.0)]);
let marks = s.overlays(v).iter().filter(|o| matches!(o, vectorcraft_tools::Overlay::Line { a, b, .. } if a.y == b.y)).count();
gesture(s, v, &[(Up, 186.0, 330.0)]);
let at = top_left(s, m).0;
s.execute("edit.undo", &json!({})).unwrap();
(at, marks)
};
let (at, marks) = drag(&mut s);
assert_eq!(at, 160.0, "20 pt from it");
assert!(marks >= 2, "both gaps marked");
set_pref(&mut s, "spacingGuides", json!(false));
assert_eq!(drag(&mut s), (161.0, 0), "off: where it was dragged");
}
/// Preferences › Smart Guides (#394): Color, Alignment Guides, Anchor/Path Labels, Measurement
/// Labels, Transform Tools and Snapping Tolerance change what the Selection tool shows while a
/// square is dragged into line with another, and how far the pull reaches; a hidden guide still
+133
View File
@@ -0,0 +1,133 @@
//! The Photoshop-style raster effects (Effect › Blur › Radial Blur and Smart Blur, Sharpen ›
//! Unsharp Mask): applied and edited as commands, listed in the catalogue, drawn on the canvas,
//! and written to PDF and SVG as images of the effected object.
use serde_json::{Value, json};
use vectorcraft_doc::NodeKind;
use vectorcraft_geom::Rect;
use vectorcraft_render::{Rendered, Renderer};
use super::*;
/// The new effects with parameters that change a striped square visibly.
const EFFECTS: [(&str, &str); 3] = [
("blur.radial", r#"{"amount": 30, "quality": "draft"}"#),
("blur.smart", r#"{"radius": 8, "threshold": 100}"#),
("sharpen.unsharpMask", r#"{"amount": 300, "radius": 3}"#),
];
/// A group of a red square and a darker red stripe across it; returns the group's id.
fn striped() -> (Session, u64) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
let id = s.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap();
s.execute("paint.setFill", &json!({"color": "#e03020", "ids": [id]})).unwrap();
s.execute("paint.setStroke", &json!({"none": true, "ids": [id]})).unwrap();
let stripe = s.execute("shape.rectangle", &json!({"x": 90, "y": 50, "width": 20, "height": 100})).unwrap()["id"].as_u64().unwrap();
s.execute("paint.setFill", &json!({"color": "#b04848", "ids": [stripe]})).unwrap();
s.execute("paint.setStroke", &json!({"none": true, "ids": [stripe]})).unwrap();
s.execute("select.set", &json!({"ids": [id, stripe]})).unwrap();
let g = s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap();
(s, g)
}
fn render(s: &Session) -> Rendered {
Renderer::new().render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true)
}
fn mean_diff(a: &Rendered, b: &Rendered) -> f64 {
let sum: u64 = a.pixels.iter().zip(&b.pixels).map(|(x, y)| x.abs_diff(*y) as u64).sum();
sum as f64 / a.pixels.len() as f64 / 255.0
}
#[test]
fn listed_applied_edited_and_undone() {
let (mut s, g) = striped();
let list = s.execute("effect.list", &json!({})).unwrap();
for (id, _) in EFFECTS {
let e = list["catalog"].as_array().unwrap().iter().find(|e| e["id"] == id).unwrap_or_else(|| panic!("{id} listed"));
assert_eq!(e["raster"], json!(true), "{id}");
assert!(e["params"].as_str().unwrap().contains("radius") || id == "blur.radial", "{id}");
}
let menus: Vec<Value> =
EFFECTS.iter().map(|(id, _)| list["catalog"].as_array().unwrap().iter().find(|e| e["id"] == *id).unwrap()["menu"].clone()).collect();
assert_eq!(menus, [json!(["Effect", "Blur"]), json!(["Effect", "Blur"]), json!(["Effect", "Sharpen"])]);
// Junk parameters are stored as given and read clamped; the canvas still draws.
for (id, _) in EFFECTS {
s.execute("effect.apply", &json!({"effect": id, "ids": [g], "params": {"amount": 1e308, "radius": -1e308, "threshold": "x", "quality": 7}}))
.unwrap();
}
let _ = render(&s);
let n = s.doc().unwrap().doc.node(NodeId(g)).unwrap().clone();
assert_eq!(n.appearance.effects.len(), 3);
assert_eq!(n.appearance.effects[0].params["method"], json!("spin"), "defaults fill the rest");
s.execute("effect.setParams", &json!({"index": 0, "ids": [g], "params": {"method": "zoom"}})).unwrap();
assert_eq!(s.doc().unwrap().doc.node(NodeId(g)).unwrap().appearance.effects[0].params["method"], json!("zoom"));
for _ in 0..4 {
s.execute("edit.undo", &json!({})).unwrap();
}
assert!(s.doc().unwrap().doc.node(NodeId(g)).unwrap().appearance.effects.is_empty());
}
#[test]
fn each_effect_changes_the_canvas_and_draws_the_same_twice() {
for (id, params) in EFFECTS {
let (mut s, g) = striped();
let plain = render(&s);
let params: Value = serde_json::from_str(params).unwrap();
s.execute("effect.apply", &json!({"effect": id, "ids": [g], "params": params})).unwrap();
let a = render(&s);
assert!(mean_diff(&plain, &a) > 0.0005, "{id} shows: {}", mean_diff(&plain, &a));
assert_eq!(a.pixels, render(&s).pixels, "{id} is deterministic");
// The multithreaded pipeline draws the same.
let mut mt = Renderer::new();
mt.threads = 4;
let b = mt.render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true);
assert!(mean_diff(&a, &b) < 0.002, "{id}: multithreaded differs by {}", mean_diff(&a, &b));
}
}
#[test]
fn spin_blur_reaches_past_the_corners() {
let (mut s, g) = striped();
s.execute("effect.apply", &json!({"effect": "blur.radial", "ids": [g], "params": {"amount": 60, "quality": "good"}})).unwrap();
let img = render(&s);
// Turned corners sweep beyond the square's sides (centre 100, 100; corner 70.7 pt out).
let p = img.pixel(100, 46);
assert!(p[1] < 250, "the arc of the corners reaches above the square: {p:?}");
assert!(img.pixel(100, 20)[1] > 250, "but not beyond the corners' circle");
}
#[test]
fn pdf_and_svg_write_the_effected_object_as_an_image() {
for (id, params) in EFFECTS {
let (mut s, g) = striped();
let params: Value = serde_json::from_str(params).unwrap();
s.execute("effect.apply", &json!({"effect": id, "ids": [g], "params": params})).unwrap();
let doc = s.doc().unwrap().doc.clone();
// PDF: an image that reads back as the canvas shows it.
let r = crate::cmd::rasterfx::export_pdf_with_report(&doc, &Default::default()).unwrap();
assert!(r.warnings.is_empty(), "{id}: {:?}", r.warnings);
let back = vectorcraft_pdf::import(&r.bytes).unwrap();
let diff = mean_diff(&render(&s), &Renderer::new().render_region(&back, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true));
assert!(diff < 0.03, "{id}: PDF reads back {:.2}% off", diff * 100.0);
// SVG: the object becomes an image; a Gaussian Blur alone stays a live filter.
let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap()["text"].as_str().unwrap().to_string();
assert!(svg.contains("<image") && !svg.contains("<filter"), "{id}: {svg}");
let flat = crate::cmd::rasterfx::flatten_pixel_effects(&doc).unwrap();
assert!(matches!(flat.layers[0].children().unwrap()[0].kind, NodeKind::Image(_)), "{id}");
}
let (mut s, g) = striped();
s.execute("effect.apply", &json!({"effect": "blur.gaussian", "ids": [g]})).unwrap();
assert!(crate::cmd::rasterfx::flatten_pixel_effects(&s.doc().unwrap().doc).is_none());
let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap()["text"].as_str().unwrap().to_string();
assert!(svg.contains("feGaussianBlur") && !svg.contains("<image"));
}
#[test]
fn expand_appearance_makes_an_image() {
let (mut s, g) = striped();
s.execute("effect.apply", &json!({"effect": "sharpen.unsharpMask", "ids": [g]})).unwrap();
s.execute("effect.expandAppearance", &json!({"ids": [g]})).unwrap();
assert!(matches!(s.doc().unwrap().doc.node(NodeId(g)).unwrap().kind, NodeKind::Image(_)));
}
+31
View File
@@ -260,6 +260,37 @@ fn the_saved_view_round_trips_without_dirtying() {
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn open_layers_rows_round_trip_without_dirtying() {
let d = dir("open-rows");
let mut s = session();
let layer = s.doc().unwrap().doc.layers[0].id;
let a = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [a]})).unwrap();
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
// A document starts with its top-level layers open, and a file keeps nothing for that.
assert!(s.doc().unwrap().layers_open.contains(layer) && !s.doc().unwrap().layers_open.contains(g));
let native = path(&d, "rows.vectorcraft");
s.execute("document.save", &json!({"path": native})).unwrap();
let bytes = std::fs::read(&native).unwrap();
assert_eq!(vectorcraft_format::load(&bytes).unwrap().layers_open, None);
// Opening the group and closing the layer is view state: not an edit, kept by the file.
let st = s.doc_mut().unwrap();
st.layers_open.set(g, true);
st.layers_open.set(layer, false);
assert!(!s.doc().unwrap().is_dirty());
s.execute("document.save", &json!({"path": native})).unwrap();
assert_eq!(s.doc().unwrap().doc.layers_open, None, "only the written file carries it");
let mut again = session();
again.execute("document.open", &json!({"path": native})).unwrap();
let open = &again.doc().unwrap().layers_open;
assert!(open.contains(g) && !open.contains(layer));
// Older files (none saved) open with the top-level layers open.
let doc = vectorcraft_format::load(&vectorcraft_format::save(&vectorcraft_doc::Document::new(5.0, 5.0), false)).unwrap();
assert_eq!(doc.layers_open, None);
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn save_plans_suggest_names_and_folders() {
let mut s = session();
+68
View File
@@ -197,6 +197,74 @@ fn another_documents_swatches_load_as_a_library() {
assert!(s.execute("swatch.library.load", &json!({"data": "hello", "name": "x.txt"})).is_err());
}
#[test]
fn swatch_exchange_files_load_add_and_list_as_user_libraries() {
let bytes = vectorcraft_testkit::ase::sample();
let mut s = session();
let r = run(&mut s, "swatch.library.load", json!({"dataBase64": vectorcraft_format::base64_encode(&bytes), "name": "Brand.ase"}));
assert_eq!((r["library"].as_str(), r["name"].as_str(), r["count"].as_u64()), (Some("loaded/Brand.ase"), Some("Brand"), Some(4)));
let g = run(&mut s, "swatch.library.get", json!({"library": r["library"]}));
let swatches = g["swatches"].as_array().unwrap();
let kinds: Vec<(&str, bool, bool)> =
swatches.iter().map(|w| (w["name"].as_str().unwrap(), w["global"].as_bool().unwrap(), w["spot"].as_bool().unwrap())).collect();
assert_eq!(kinds, [("Sky", true, false), ("Ink", true, true), ("Mist", false, false), ("Clay", false, false)]);
assert_eq!(swatches[3]["color"], json!({"model": "lab", "l": 50.0, "a": 20.0, "b": -30.0}));
// Added to the document, the spot color stays a spot color and the group stays a color group.
run(&mut s, "swatch.library.add", json!({"library": r["library"]}));
let d = doc(&s);
assert!(d.swatch("Ink").is_some_and(|w| w.spot && w.global));
assert_eq!(d.swatch_groups.iter().find(|g| g.name == "Neutrals").map(|g| g.swatches.len()), Some(2));
// A damaged file is an error from the swatch exchange reader, not from the document loader.
let cut = vectorcraft_format::base64_encode(&bytes[..bytes.len() - 1]);
let e = s.execute("swatch.library.load", &json!({"dataBase64": cut, "name": "Cut.ase"})).unwrap_err().to_string();
assert!(e.contains("cut short"), "{e}");
// `.ase` files in the user library folder are User Defined libraries.
let dir = temp_dir("ase");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("Brand.ase"), &bytes).unwrap();
s.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string()));
let list = run(&mut s, "swatch.library.list", json!({}));
let user: Vec<(&str, u64)> = list["libraries"]
.as_array()
.unwrap()
.iter()
.filter(|l| l["category"] == "user")
.map(|l| (l["id"].as_str().unwrap(), l["count"].as_u64().unwrap()))
.collect();
assert_eq!(user, [("user/Brand.ase", 4)]);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn a_library_group_stays_one_group_when_a_swatch_has_its_name() {
use vectorcraft_testkit::ase::{Block, ase};
let mut s = session();
assert!(doc(&s).swatch("Blue").is_some(), "a new document has a swatch named Blue");
let bytes = ase(&[
Block::GroupStart("Blue"),
Block::Color("Deep", b"RGB ", &[0.0, 0.0, 0.4], 2),
Block::Color("Pale", b"RGB ", &[0.6, 0.7, 1.0], 2),
Block::GroupEnd,
Block::GroupStart("Blue 2"),
Block::Color("Frost", b"RGB ", &[0.9, 0.95, 1.0], 2),
Block::GroupEnd,
]);
let r = run(&mut s, "swatch.library.load", json!({"dataBase64": vectorcraft_format::base64_encode(&bytes), "name": "Blues.ase"}));
run(&mut s, "swatch.library.add", json!({"library": r["library"]}));
let groups: Vec<(&str, Vec<&str>)> =
doc(&s).swatch_groups.iter().map(|g| (g.name.as_str(), g.swatches.iter().map(|w| w.name.as_str()).collect())).collect();
assert!(groups.contains(&("Blue 2", vec!["Deep", "Pale"])), "{groups:?}");
assert!(groups.contains(&("Blue 2 2", vec!["Frost"])), "the library's own Blue 2 stays apart: {groups:?}");
}
#[test]
fn text_libraries_sent_as_bytes_load_when_they_are_not_valid_utf8() {
let mut s = session();
let latin1 = vectorcraft_format::base64_encode(b"GIMP Palette\n237 28 36 Rouge fonc\xe9\n");
let r = run(&mut s, "swatch.library.load", json!({"dataBase64": latin1, "name": "Rouge.gpl"}));
assert_eq!(r["count"], 1);
}
#[test]
fn gradient_libraries_are_listed_and_gradient_swatches_rename_and_take_new_gradients() {
let mut s = session();
+310
View File
@@ -0,0 +1,310 @@
//! The text engine's newer features combined through commands: Auto Size and Shrink Text to Fit
//! with inline graphics, per-paragraph attributes, the paragraph composer and vertical
//! alignment; editing and undo across them; and a save round trip with all of them on.
//! (`vectorcraft_text`'s `tests_combos` covers the layout side.)
use serde_json::{Value, json};
use vectorcraft_doc::text::INLINE_CHAR;
use vectorcraft_doc::{AreaFit, Composer, Justify, NodeKind, TextKind, TextObject, VerticalAlign};
use vectorcraft_geom::Rect;
use super::*;
const STORY: &str = "The quick brown fox jumps over the lazy dog. A second sentence follows the first one here. Then a third one ends it.";
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
/// A symbol `name`: a circle `d` points across, its instance deleted.
fn circle_symbol(s: &mut Session, name: &str, d: f64) {
s.execute("shape.ellipse", &json!({"x": 600, "y": 500, "width": d, "height": d})).unwrap();
s.execute("symbol.new", &json!({"name": name})).unwrap();
s.execute("edit.clear", &json!({})).unwrap();
}
/// Area type at (40, 40), `w` × `h`, size 12, with `extra` area options; selected.
fn area(s: &mut Session, w: f64, h: f64, text: &str, extra: Value) -> NodeId {
let mut a = json!({"width": w, "height": h});
if let (Some(a), Some(e)) = (a.as_object_mut(), extra.as_object()) {
a.extend(e.clone());
}
let id = NodeId(s.execute("text.create", &json!({"x": 40, "y": 40, "size": 12, "area": a, "text": text})).unwrap()["id"].as_u64().unwrap());
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
id
}
fn text(s: &Session, id: NodeId) -> TextObject {
match &s.doc().unwrap().doc.node(id).unwrap().kind {
NodeKind::Text(t) => (**t).clone(),
k => panic!("not text: {k:?}"),
}
}
fn frame(s: &Session, id: NodeId) -> Rect {
match text(s, id).kind {
TextKind::Area { frame } => frame.bounds().unwrap(),
k => panic!("not area type: {k:?}"),
}
}
fn lay(t: &TextObject) -> vectorcraft_text::TextLayout {
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t)
}
/// Auto Size's frame is tight: the text fits, and a point less overflows.
fn assert_tight(s: &Session, id: NodeId, what: &str) {
let t = text(s, id);
assert!(!lay(&t).overflow, "{what}: the frame holds the text");
let b = frame(s, id);
let mut less = t.clone();
less.area.fit = AreaFit::None;
less.kind = TextKind::Area {
frame: vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::Shape::to_path(&Rect::new(b.x0, b.y0, b.x1, b.y1 - 1.0), 0.1)),
};
assert!(lay(&less).overflow, "{what}: a point less overflows ({b:?})");
}
fn undo(s: &mut Session) {
s.execute("edit.undo", &json!({})).unwrap();
}
fn redo(s: &mut Session) {
s.execute("edit.redo", &json!({})).unwrap();
}
fn query(s: &mut Session) -> Value {
s.execute("text.areaOptions", &json!({})).unwrap()
}
#[test]
fn auto_height_x_inline_graphics() {
let mut s = session();
circle_symbol(&mut s, "Tap", 10.0);
let id = area(&mut s, 200.0, 300.0, "Tap: add one mana.", json!({"fit": "autoHeight"}));
let one = frame(&s, id);
assert_tight(&s, id, "one line");
// Art taller than the line makes the line, and so the frame, taller: in the same undo step.
let n = s.doc().unwrap().history.undo.len();
s.execute("text.insertInline", &json!({"id": id.0, "at": 0, "symbol": "Tap", "scale": 3})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), n + 1);
let tall = frame(&s, id);
assert!(tall.height() > one.height() + 5.0, "{one:?} → {tall:?}");
assert_tight(&s, id, "tall art");
// Enough art to wrap: another line.
for _ in 0..12 {
s.execute("text.insertInline", &json!({"id": id.0, "at": 0, "symbol": "Tap", "scale": 1})).unwrap();
}
assert!(lay(&text(&s, id)).lines.len() > 1);
assert_tight(&s, id, "wrapped art");
// Deleting the art shrinks it back; undo restores each step exactly.
let wrapped = frame(&s, id);
let all = text(&s, id).plain_text().rfind(INLINE_CHAR).unwrap() + INLINE_CHAR.len_utf8();
s.execute("text.editRange", &json!({"id": id.0, "start": 0, "end": all})).unwrap();
assert_eq!(text(&s, id).plain_text(), "Tap: add one mana.");
assert_eq!(frame(&s, id), one);
undo(&mut s);
assert_eq!(frame(&s, id), wrapped);
for _ in 0..13 {
undo(&mut s);
}
assert_eq!(frame(&s, id), one);
redo(&mut s);
assert_eq!(frame(&s, id), tall);
}
#[test]
fn auto_height_x_paragraph_attrs() {
let mut s = session();
let id = area(&mut s, 200.0, 300.0, "One\nTwo", json!({"fit": "autoHeight"}));
let h0 = frame(&s, id).height();
// Space before on the second paragraph only: the frame grows by exactly that.
s.execute("text.setFormat", &json!({"ids": [id.0], "spaceBefore": 30, "start": 4, "end": 4})).unwrap();
let t = text(&s, id);
assert_eq!((t.para_at(0).space_before, t.para_at(1).space_before), (0.0, 30.0));
let h1 = frame(&s, id).height();
assert!((h1 - h0 - 30.0).abs() < 1e-6, "{h0} → {h1}");
assert_tight(&s, id, "space before");
// Return in the spaced paragraph: the new one inherits the spacing, the frame follows.
s.execute("text.editRange", &json!({"id": id.0, "start": 7, "end": 7, "insert": "\nThree"})).unwrap();
let t = text(&s, id);
assert_eq!(t.paragraph_styles().iter().map(|p| p.space_before).collect::<Vec<_>>(), [0.0, 30.0, 30.0]);
assert!(frame(&s, id).height() > h1 + 30.0);
assert_tight(&s, id, "three paragraphs");
// Merging paragraphs: the first one's attributes win, the frame shrinks.
s.execute("text.editRange", &json!({"id": id.0, "start": 3, "end": 4})).unwrap();
let t = text(&s, id);
assert_eq!(t.paragraph_styles().iter().map(|p| p.space_before).collect::<Vec<_>>(), [0.0, 30.0]);
assert_tight(&s, id, "merged");
undo(&mut s);
undo(&mut s);
assert!((frame(&s, id).height() - h1).abs() < 1e-9);
undo(&mut s);
assert!((frame(&s, id).height() - h0).abs() < 1e-9);
assert!(text(&s, id).paras.is_empty(), "back to one style for all");
}
#[test]
fn auto_height_x_composer() {
// Switching the composer of one paragraph rebreaks it; Auto Size follows either way.
let mut s = session();
let story = format!("{STORY} {STORY}\n{STORY}");
let id = area(&mut s, 130.0, 100.0, &story, json!({"fit": "autoHeight"}));
assert_tight(&s, id, "every-line");
for composer in ["singleLine", "everyLine", "singleLine"] {
s.execute("text.setFormat", &json!({"ids": [id.0], "composer": composer, "start": 0, "end": 0})).unwrap();
let t = text(&s, id);
assert_eq!(t.para_at(1).composer, Composer::EveryLine, "the second paragraph keeps its own");
assert_tight(&s, id, composer);
}
s.execute("text.setFormat", &json!({"ids": [id.0], "composer": "singleLine"})).unwrap();
s.execute("text.setStyle", &json!({"id": id.0, "justify": "justifyAll"})).unwrap();
assert!(text(&s, id).paragraph_styles().iter().all(|p| p.composer == Composer::SingleLine && p.justify == Justify::JustifyAll));
assert_tight(&s, id, "justified");
}
#[test]
fn auto_height_x_vertical_align() {
// An Auto Size frame has no space to spare: every alignment sets the lines as Top does, and
// the frame is as tall as with Top.
let mut s = session();
let id = area(&mut s, 160.0, 100.0, STORY, json!({"fit": "autoHeight"}));
let top_frame = frame(&s, id);
let top = lay(&text(&s, id));
for a in ["center", "bottom", "justify"] {
s.execute("text.areaOptions", &json!({"verticalAlign": a})).unwrap();
assert_eq!(frame(&s, id), top_frame, "{a}");
let l = lay(&text(&s, id));
for (li, l0) in l.lines.iter().zip(&top.lines) {
assert!((li.baseline - l0.baseline).abs() < 1e-6, "{a}: {} vs {}", li.baseline, l0.baseline);
}
assert_tight(&s, id, a);
}
// Editing with the alignment on keeps the frame tight.
s.execute("text.editRange", &json!({"id": id.0, "start": 0, "end": 0, "insert": format!("{STORY} ")})).unwrap();
assert_eq!(text(&s, id).area.vertical_align, VerticalAlign::Justify);
assert_tight(&s, id, "longer, justified");
}
#[test]
fn shrink_text_x_inline_graphics_and_paragraphs() {
let mut s = session();
circle_symbol(&mut s, "Dot", 8.0);
let id = area(&mut s, 160.0, 60.0, "Short rules.\nFlavor.", json!({"fit": "shrinkText", "fitMinPercent": 20}));
assert_eq!(query(&mut s)["fitScale"], 1.0);
// Inline art and per-paragraph spacing push it over; shrinking makes it fit.
s.execute("text.setFormat", &json!({"ids": [id.0], "spaceBefore": 12, "start": 13, "end": 13})).unwrap();
for i in 0..10 {
s.execute("text.insertInline", &json!({"id": id.0, "at": 0, "symbol": "Dot", "scale": 1.0 + i as f64 * 0.2})).unwrap();
s.execute("text.editRange", &json!({"id": id.0, "start": 0, "end": 0, "insert": "Add "})).unwrap();
}
let q = query(&mut s);
assert_eq!(q["overflow"], false, "{q}");
let f = q["fitScale"].as_f64().unwrap();
assert!((0.2..1.0).contains(&f), "{f}");
let t = text(&s, id);
assert_eq!(t.paragraph_count(), 2);
assert_eq!(t.para_at(1).space_before, 12.0, "inserting in the first paragraph keeps the second's attributes");
assert_eq!(t.runs[0].style.size, 12.0, "the stored size stays");
let l = lay(&t);
assert_eq!(l.inlines.len(), 10);
assert!(l.inlines.iter().all(|i| i.bounds.height() < 12.0 * 2.8 * f + 1e-6));
// Undo walks the fit back up.
for _ in 0..20 {
undo(&mut s);
}
assert_eq!(query(&mut s)["fitScale"], 1.0);
}
#[test]
fn paragraph_attrs_x_inline_editing() {
// An inline graphic opening a paragraph belongs to it: inserting it keeps the paragraph's
// attributes, deleting it with the paragraph break merges into the paragraph before.
let mut s = session();
circle_symbol(&mut s, "Tap", 10.0);
let id = area(&mut s, 300.0, 200.0, "Rules\nFlavor", json!({}));
s.execute("text.setStyle", &json!({"id": id.0, "justify": "center", "start": 6, "end": 6})).unwrap();
s.execute("text.setFormat", &json!({"ids": [id.0], "composer": "singleLine", "start": 6, "end": 6})).unwrap();
s.execute("text.insertInline", &json!({"id": id.0, "at": 6, "symbol": "Tap"})).unwrap();
let t = text(&s, id);
assert_eq!(t.plain_text(), format!("Rules\n{INLINE_CHAR}Flavor"));
assert_eq!((t.para_at(0).justify, t.para_at(1).justify), (Justify::Auto, Justify::Center));
assert_eq!(t.para_at(1).composer, Composer::SingleLine);
// The art's line is centred.
let l = lay(&t);
let g = &l.glyphs[l.inlines[0].glyph];
let line = &l.lines[g.line];
assert!(((line.x0 + line.x1) * 0.5 - 150.0).abs() < 1e-6, "centred in the 300 pt frame: {line:?}");
assert!((l.inlines[0].bounds.x0 - g.origin.x).abs() < 1e-6);
// Return just before the art: the new paragraph (holding the art) continues the centred one.
s.execute("text.editRange", &json!({"id": id.0, "start": 6, "end": 6, "insert": "Text\n"})).unwrap();
let t = text(&s, id);
assert_eq!(t.paragraph_styles().iter().map(|p| p.justify).collect::<Vec<_>>(), [Justify::Auto, Justify::Center, Justify::Center]);
undo(&mut s);
// Delete the break and the art together: one paragraph, the first one's attributes.
let end = 6 + INLINE_CHAR.len_utf8();
s.execute("text.editRange", &json!({"id": id.0, "start": 5, "end": end})).unwrap();
let t = text(&s, id);
assert_eq!(t.plain_text(), "RulesFlavor");
assert!(t.paras.is_empty() && t.para.justify == Justify::Auto && t.para.composer == Composer::EveryLine);
assert!(t.runs.iter().all(|r| r.inline.is_none()));
undo(&mut s);
let t = text(&s, id);
assert_eq!(t.plain_text(), format!("Rules\n{INLINE_CHAR}Flavor"));
assert_eq!(t.para_at(1).justify, Justify::Center);
assert_eq!(t.runs.iter().filter(|r| r.inline.is_some()).count(), 1);
}
#[test]
fn vertical_align_x_inline_graphics_in_the_document() {
// The art the renderer draws (the layout's inline placements) moves with centred lines.
let mut s = session();
circle_symbol(&mut s, "Tap", 10.0);
let id = area(&mut s, 300.0, 200.0, "Add one mana.", json!({}));
s.execute("text.insertInline", &json!({"id": id.0, "at": 0, "symbol": "Tap"})).unwrap();
let top = lay(&text(&s, id));
s.execute("text.areaOptions", &json!({"verticalAlign": "center"})).unwrap();
let l = lay(&text(&s, id));
let d = l.lines[0].baseline - top.lines[0].baseline;
assert!(d > 50.0, "{d}");
assert!((l.inlines[0].bounds.y0 - top.inlines[0].bounds.y0 - d).abs() < 1e-6, "{:?} vs {:?}", l.inlines[0].bounds, top.inlines[0].bounds);
// The cached bounds (hit testing, selection) include the moved art.
let cached = text(&s, id).cached_bounds.unwrap();
assert!(cached.contains(l.inlines[0].bounds.center()), "{cached:?}");
}
#[test]
fn every_feature_saves_and_reopens() {
let mut s = session();
circle_symbol(&mut s, "Tap", 10.0);
let id = area(
&mut s,
220.0,
70.0,
&format!(": Add one mana of any color. {STORY}\n{STORY}"),
json!({"verticalAlign": "center", "fit": "shrinkText", "fitMinPercent": 30}),
);
s.execute("text.insertInline", &json!({"id": id.0, "at": 0, "symbol": "Tap", "scale": 0.8, "shift": 0.5})).unwrap();
let flavor = text(&s, id).plain_text().find('\n').unwrap() + 1;
s.execute("text.setFormat", &json!({"ids": [id.0], "spaceBefore": 8, "composer": "singleLine", "start": flavor, "end": flavor})).unwrap();
s.execute("text.setStyle", &json!({"id": id.0, "justify": "justifyAll", "start": flavor, "end": flavor})).unwrap();
s.execute("text.setStyle", &json!({"id": id.0, "tracking": 20})).unwrap();
let t = text(&s, id);
let l = lay(&t);
assert!(!l.overflow && l.fit_scale < 1.0, "{}", l.fit_scale);
assert_eq!(l.inlines.len(), 1);
let doc = s.doc().unwrap().doc.clone();
let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap();
let NodeKind::Text(t2) = &back.node(id).unwrap().kind else { panic!("not text") };
let mut t2 = (**t2).clone();
// Resolved art bounds and layout caches aren't saved: resolve them as opening does.
back.resolve_text_inline(&mut t2);
assert_eq!(t2.runs, t.runs);
assert_eq!(t2.paragraph_styles(), t.paragraph_styles());
assert_eq!(t2.area, t.area);
let l2 = lay(&t2);
assert_eq!((l2.fit_scale, l2.lines.len()), (l.fit_scale, l.lines.len()));
assert_eq!(l2.inlines.iter().map(|i| i.bounds).collect::<Vec<_>>(), l.inlines.iter().map(|i| i.bounds).collect::<Vec<_>>());
}
+5 -2
View File
@@ -247,10 +247,13 @@ fn artboard_tool_moves_with_art_creates_and_deletes() {
assert_eq!(s.doc().unwrap().doc.artboards.len(), 2);
assert_eq!(s.tool_options()["active"], 1);
assert_eq!(s.doc().unwrap().doc.artboards[1].rect, Rect::new(1000.0, 0.0, 1200.0, 100.0));
// Delete it.
// Delete it: the tool takes the key ahead of the Clear shortcut (the UI only hands Delete to a
// tool that claims it).
assert!(s.tool_claims_key(ToolKey::Delete, ViewInfo::default()));
s.tool_key(ToolKey::Delete, Mods::default(), ViewInfo::default()).unwrap();
assert_eq!(s.doc().unwrap().doc.artboards.len(), 1);
// The last artboard can't be deleted.
// The last artboard can't be deleted, so Delete stays the shortcut's.
assert!(!s.tool_claims_key(ToolKey::Delete, ViewInfo::default()));
s.tool_key(ToolKey::Delete, Mods::default(), ViewInfo::default()).unwrap();
assert_eq!(s.doc().unwrap().doc.artboards.len(), 1);
}
+1
View File
@@ -223,6 +223,7 @@ impl Session {
anchor_path_labels: self.prefs.anchor_path_labels,
measurement_labels: self.prefs.measurement_labels,
transform_tools_guides: self.prefs.transform_tools_guides,
spacing_guides: self.prefs.spacing_guides,
snapping_tolerance: self.prefs.snapping_tolerance,
construction_angles: if self.prefs.construction_guides {
vectorcraft_tools::guides::construction_angles(&self.prefs.construction_angles)
+91
View File
@@ -0,0 +1,91 @@
//! Public Affinity documents (`cargo xtask corpus --affinity`, pinned and sha256-verified) open
//! natively, and their art renders like Affinity's own picture of them: every document embeds a
//! thumbnail Affinity rendered when saving it. Each file's mean difference from that thumbnail
//! (0–255 per channel, both flattened on white, at the thumbnail's size) must stay under its
//! ceiling, which sits just above the difference measured when the importer landed.
//!
//! Skips when `corpus/affinity` is absent.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::path::PathBuf;
/// (file, ceiling). Measured differences when the importer landed are in the comments.
const FILES: &[(&str, f64)] = &[
("affinity3-mexican-guy.af", 3.0), // 1.80: brush strokes as plain strokes
("affinity3-mexican-man.af", 3.5), // 2.24
("affinity3-mexican-woman.af", 3.5), // 2.55
("affinity3-playing-cards.af", 5.5), // 4.32: layer effects
("designer-beer.afdesign", 4.0), // 2.90
("designer-cactus.afdesign", 1.5), // 0.33
("designer-car.afdesign", 1.5), // 0.40
("designer-flowers.afdesign", 3.0), // 2.01
("designer-lion-track.afdesign", 5.5), // 4.34
("afdesignload-color.afdesign", 1.0), // 0.00
("afdesignload-layer_mode.afdesign", 1.0), // 0.00
("afdesignload-layer_test.afdesign", 1.0), // 0.00
("afdesignload-margins.afdesign", 1.0), // 0.00
("afdesignload-raster_test.afdesign", 2.0), // 0.72: pixel layer
("afdesignload-revision_test.afdesign", 1.0),
("afdesignload-shape_test.afdesign", 4.5), // 3.50: cloud and heart shapes as ellipses
("afdesignload-slice_test.afdesign", 1.0),
("afdesignload-test_path.afdesign", 1.5), // 0.26
("jac21-SimpleLogo.afdesign", 1.5), // 0.19
("jac21-SimpleLogoBanner.afdesign", 5.5), // 4.42: placed images in a Display P3 document
("eviltwo-AssetStore.aftemplate", 1.0), // 0.00: artboards
("leakcanary-vector_icon.afdesign", 3.5), // 2.33
];
fn corpus() -> Option<PathBuf> {
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../corpus/affinity");
dir.join("SOURCES.md").exists().then_some(dir)
}
/// Mean absolute difference of two RGBA8 images of one size, both composited on white.
fn difference(a: &[u8], b: &[u8]) -> f64 {
let white = |p: &[u8]| -> [f64; 3] {
let al = f64::from(p[3]) / 255.0;
[0, 1, 2].map(|i| f64::from(p[i]) * al + 255.0 * (1.0 - al))
};
let (mut sum, mut n) = (0.0, 0usize);
for (p, q) in a.chunks_exact(4).zip(b.chunks_exact(4)) {
let (x, y) = (white(p), white(q));
sum += (0..3).map(|i| (x[i] - y[i]).abs()).sum::<f64>();
n += 3;
}
sum / n.max(1) as f64
}
#[test]
fn public_affinity_documents_render_like_their_affinity_thumbnails() {
let Some(dir) = corpus() else {
eprintln!("corpus/affinity is absent: run `cargo xtask corpus --affinity`");
return;
};
let mut failures = Vec::new();
for (name, ceiling) in FILES {
let bytes = std::fs::read(dir.join(name)).unwrap();
let loaded = vectorcraft_engine::cmd::fileio::load(name, &bytes).unwrap();
assert!(!loaded.preview_only, "{name} opened as its preview: {:?}", loaded.warnings);
let d = &loaded.doc;
let thumb = image::load_from_memory(vectorcraft_affinity::preview(&bytes).unwrap().png).unwrap().to_rgba8();
// The thumbnail shows the first page, or every artboard: compare the largest artboard
// with its part of the thumbnail.
let union = d.artboards.iter().map(|a| a.rect).reduce(|a, b| a.union(b)).unwrap();
let scale = f64::from(thumb.width()) / union.width();
let board = d.artboards.iter().max_by(|a, b| a.rect.area().total_cmp(&b.rect.area())).unwrap().rect;
let crop = |v: f64| (v * scale).round().max(0.0) as u32;
let (x, y) = (crop(board.x0 - union.x0), crop(board.y0 - union.y0));
let (w, h) = (crop(board.width()).min(thumb.width() - x), crop(board.height()).min(thumb.height() - y));
let expected = image::imageops::crop_imm(&thumb, x, y, w, h).to_image();
let rendered = vectorcraft_render::Renderer::new().render_region(d, board, scale, false);
let got = image::RgbaImage::from_raw(rendered.width, rendered.height, rendered.to_straight()).unwrap();
let got = image::imageops::resize(&got, w, h, image::imageops::FilterType::Triangle);
let diff = difference(expected.as_raw(), got.as_raw());
eprintln!("{name}: {diff:.2} (ceiling {ceiling})");
if diff > *ceiling {
failures.push(format!("{name}: {diff:.2} > {ceiling}"));
}
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
+91 -4
View File
@@ -98,13 +98,19 @@ fn known_bug(id: &str, p: &Value) -> bool {
KNOWN_BUGS.iter().any(|(c, k)| *c == id && p.get(*k).is_some())
}
/// Regression: huge column counts used to panic with `capacity overflow`.
/// Regression: huge column counts used to panic with `capacity overflow`; in every layout and
/// order (#681), with huge and negative spacing too.
#[test]
fn artboard_rearrange_huge_columns() {
for fx in Fixture::ALL {
for cols in [json!(u64::MAX), json!(1e308)] {
let p = json!({ "columns": cols });
assert_eq!(probe(fx, "artboard.rearrange", &p), None);
for layout in ["gridByRow", "gridByColumn", "row", "column"] {
for order in ["leftToRight", "rightToLeft"] {
for (cols, spacing) in [(json!(u64::MAX), json!(1e308)), (json!(1e308), json!(-1e308)), (json!(0), json!(0)), (json!(-5), json!(20))]
{
let p = json!({ "layout": layout, "order": order, "columns": cols, "spacing": spacing });
assert_eq!(probe(fx, "artboard.rearrange", &p), None);
}
}
}
}
}
@@ -251,6 +257,9 @@ fn structured_junk() {
("effect.apply", json!({"effect": "distort.zigZag", "params": {"size": 1e308, "ridges": 100}})),
("effect.apply", json!({"effect": "distort.transform", "params": {"copies": 1e308}})),
("effect.apply", json!({"effect": "stylize.dropShadow", "params": {"blur": 1e308, "opacity": -1}})),
("effect.apply", json!({"effect": "blur.radial", "params": {"amount": 1e308, "method": 5, "quality": "best"}})),
("effect.apply", json!({"effect": "blur.smart", "params": {"radius": 1e308, "threshold": -1e308, "quality": null}})),
("effect.apply", json!({"effect": "sharpen.unsharpMask", "params": {"amount": -1, "radius": "1e999", "threshold": 1e308}})),
("effect.apply", json!({"effect": "no.such.effect"})),
("effect.remove", json!({"index": 99})),
("effect.setParams", json!({"index": 0, "params": null})),
@@ -501,3 +510,81 @@ fn envelope_commands_with_junk_params_on_envelopes() {
failures.dedup();
assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.iter().take(40).cloned().collect::<Vec<_>>().join("\n"));
}
/// Inline graphics in text (a symbol set in a run of type): text commands with `{}`, junk and
/// structured junk (bad symbol names, offsets inside or past the graphic's character, malformed
/// inline runs) never panic, leave an interaction open or break the tree, and the document still
/// round-trips and exports.
#[test]
fn text_commands_with_junk_params_on_inline_graphics() {
use vectorcraft_testkit::fixtures::{ellipse, exec, id_of, select};
let path = safe_path();
let setup = || {
let mut s = Fixture::Multi.session();
let e = ellipse(&mut s, 400.0, 300.0, 20.0, 20.0);
select(&mut s, &[e]);
exec(&mut s, "symbol.new", json!({"name": "Dot"}));
let t = id_of(&exec(&mut s, "text.create", json!({"x": 20, "y": 200, "text": "ab cd", "size": 20})));
exec(&mut s, "text.insertInline", json!({"id": t.0, "at": 2, "symbol": "Dot", "scale": 2}));
select(&mut s, &[t]);
(s, t)
};
let (_, t) = setup();
let t = t.0;
let style = json!({"font_family": "Source Sans 3", "size": 12, "fill": {"type": "none"}});
let structured = [
("text.insertInline", json!({"id": t, "at": u64::MAX, "symbol": "Dot", "scale": 100})),
("text.insertInline", json!({"id": t, "at": 3, "symbol": "Dot", "scale": 1e-300, "shift": -1e5})),
("text.insertInline", json!({"id": t, "at": 3, "symbol": "\u{0}", "scale": 1})),
("text.insertInline", json!({"id": t, "at": 3, "symbol": "", "scale": f64::MAX})),
("text.insertInline", json!({"id": u64::MAX, "symbol": "Dot"})),
("text.insertInline", json!({"id": t, "at": "3", "symbol": ["Dot"], "scale": "big", "shift": null})),
("text.editRange", json!({"id": t, "start": 3, "end": 4, "insert": "x"})),
("text.editRange", json!({"id": t, "start": 4, "end": 3})),
(
"text.editRange",
json!({"id": t, "start": 0, "end": 0, "runs": [{"text": "\u{FFFC}\u{FFFC}x", "style": style, "inline": {"symbol": "Dot", "scale": 1e308, "baseline_shift": -1e308}}]}),
),
("text.editRange", json!({"id": t, "start": 0, "end": 0, "runs": [{"text": "\u{FFFC}", "style": style, "inline": {"symbol": "Missing"}}]})),
("text.editRange", json!({"id": t, "start": 0, "end": 0, "runs": [{"text": "", "style": style, "inline": {"symbol": "Dot", "scale": -5}}]})),
("text.editRange", json!({"id": t, "runs": [{"text": "\u{FFFC}", "style": style, "inline": {"symbol": 5}}]})),
("text.setRangeStyle", json!({"id": t, "start": 3, "end": 4, "size": 1e308, "tracking": -1e308})),
("text.getRange", json!({"id": t, "start": 3, "end": 4})),
("type.insert", json!({"text": "\u{FFFC}"})),
("type.changeCase", json!({"case": "upper"})),
("type.smartPunctuation", json!({"quotes": true, "dashes": true, "ellipsis": true, "scope": "selection"})),
("type.convertToAreaType", json!({})),
("type.createOutlines", json!({})),
("symbol.delete", json!({"name": "Dot"})),
("symbol.delete", json!({"name": "Dot", "expandInstances": false})),
("symbol.update", json!({"name": "Dot"})),
];
let mut failures = vec![];
let mut run = |id: &str, p: &Value| {
let (mut s, _) = setup();
match catch_quiet(|| s.execute(id, p)) {
Err(msg) => failures.push(format!("PANIC {id} {p}: {msg}")),
Ok(r) => {
if s.in_interaction() {
failures.push(format!("{id} {p}: left an interaction open"));
} else if let Err(e) = check_session(&s) {
failures.push(format!("{id} {p}: {e}"));
} else if r.is_ok()
&& let Err(e) = catch_quiet(|| check_all(&mut s)).unwrap_or_else(|m| Err(format!("panic in checks: {m}")))
{
failures.push(format!("{id} {p}: {e}"));
}
}
}
};
for (id, p) in &structured {
run(id, p);
}
for id in ["text.insertInline", "text.editRange", "text.getRange", "text.setRangeStyle", "type.insert"] {
let spec = command_specs().iter().find(|c| c.id == id).unwrap();
for p in std::iter::once(json!({})).chain(junk_params(spec.params, &path)) {
run(id, &p);
}
}
assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n"));
}
+322 -18
View File
@@ -1,10 +1,11 @@
//! Untrusted files never crash the app: garbage, truncated, mutated and hostile SVG, PDF and DXF input,
//! mutated raster images placed with File → Place, and swatch (`.vcswatches`, `.gpl`), graphic style (`.vcstyles`) and flattener preset
//! mutated raster images placed with File → Place, and swatch (`.vcswatches`, `.gpl`, `.ase`), graphic style (`.vcstyles`) and flattener preset
//! (`.vcflattener`) libraries, and native files (compressed, damaged, saved for older versions),
//! 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.
//! (damaged ones, hostile programs) read by the PostScript interpreter, 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.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
@@ -25,6 +26,244 @@ fn config() -> ProptestConfig {
c
}
// ---------- Affinity previews ----------
/// Original pixels in a synthetic container envelope, not an Affinity document writer.
fn affinity_preview_sample() -> Vec<u8> {
let im = image::RgbaImage::from_pixel(2, 1, image::Rgba([220, 40, 60, 255]));
let mut png = Vec::new();
im.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let mut b = vec![0; 72];
b[..4].copy_from_slice(vectorcraft_affinity::MAGIC);
b[4..6].copy_from_slice(&12u16.to_le_bytes());
b[8..12].copy_from_slice(b"nsrP");
b[12..16].copy_from_slice(b"#Inf");
b[24..32].copy_from_slice(&72u64.to_le_bytes());
b[64..68].copy_from_slice(b"Prot");
b.extend(b"\xff\xff\xff\xffThmb");
b.extend(1u32.to_le_bytes());
b.extend((png.len() as u32 + 13).to_le_bytes());
b.extend(29u32.to_le_bytes());
b.extend(0u32.to_le_bytes());
b.extend((png.len() as u32).to_le_bytes());
b.push(1);
b.extend(png);
b
}
/// A synthetic native document exercising the reader's paths: curves (with live corners), every
/// parametric shape it knows, a compound, gradients, a stroke, text, a mask and an artboard. Not
/// an Affinity writer: the layout is the one the reader accepts.
fn affinity_native_stream() -> Vec<u8> {
use vectorcraft_affinity::synth::{self, F, tag};
let f32s = |v: &[f32]| F::Struct(v.iter().flat_map(|x| x.to_le_bytes()).collect());
let rec = |x: f64, y: f64, role: u8| {
let mut r = x.to_le_bytes().to_vec();
r.extend(y.to_le_bytes());
r.extend([1, role]);
r
};
let mut id = 100;
let mut next = || {
id += 1;
id
};
let colour = |id: u32| F::Def(id, vec![tag(b"RGBA")], vec![(tag(b"_col"), f32s(&[0.2, 0.4, 0.6, 0.8]))]);
let gradient = |a: u32, b: u32, c: u32, kind: u16| {
F::Def(
a,
vec![tag(b"FDsc")],
vec![
(
tag(b"FDeF"),
F::Def(
b,
vec![tag(b"FilG")],
vec![
(tag(b"Type"), F::Enum(kind, 0)),
(tag(b"Grad"), F::Obj(tag(b"Grad"), vec![(tag(b"Cols"), F::Shared(vec![colour(c), F::Ref(c)]))])),
],
),
),
(tag(b"FDeX"), F::F64s(vec![10.0, 0.0, 5.0, 0.0, 20.0, 5.0])),
],
)
};
let curve = F::Obj(
tag(b"PCvD"),
vec![(
tag(b"Data"),
F::Pos(vec![
F::U8(0),
F::U32(1),
F::Bool(true),
F::Records(18, vec![rec(0.0, 0.0, 0), rec(5.0, 0.0, 1), rec(10.0, 5.0, 2), rec(10.0, 10.0, 0), rec(0.0, 10.0, 0), rec(0.0, 0.0, 0)]),
]),
)],
);
let mut kids = vec![F::Def(
next(),
vec![tag(b"PCrv")],
vec![(tag(b"Crvs"), curve.clone()), (tag(b"BFFl"), F::Shared(vec![gradient(next(), next(), next(), 0)]))],
)];
for (i, class) in [b"ShNR", b"ShpE", b"ShPy", b"ShSt", b"ShSS", b"ShPi", b"ShpT", b"ShTz", b"ShCl"].into_iter().enumerate() {
let fields = vec![
(tag(b"Side"), F::U32(6)),
(tag(b"Smth"), F::Bool(i % 2 == 0)),
(tag(b"Curv"), F::F32(0.5)),
(tag(b"CTyp"), F::Raw(vec![0xaa, b'p', b'y', b'T', b'C', 4, 0, 0, 0, 0, 0, 0, 0, 4, 0, 4, 0, 4, 0])),
(tag(b"ShCR"), f32s(&[0.25, 0.0, 0.0, 0.0])),
(tag(b"AngE"), F::F32(1.0)),
];
kids.push(F::Def(
next(),
vec![tag(b"ShpN")],
vec![
(tag(b"Shpe"), F::Def(next(), vec![tag(class)], fields)),
(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 40.0 + i as f64, 30.0])),
(tag(b"Xfrm"), F::F64s(vec![1.0, 0.2, i as f64 * 10.0, 0.0, 1.0, 5.0])),
(tag(b"BFFl"), F::Shared(vec![gradient(next(), next(), next(), (i % 4) as u16)])),
(tag(b"AdCh"), F::Shared(vec![F::Def(next(), vec![tag(b"PCrv")], vec![(tag(b"Crvs"), curve.clone())])])),
],
));
}
kids.push(F::Def(
next(),
vec![tag(b"Comp")],
vec![(tag(b"Chld"), F::Shared(vec![F::Def(next(), vec![tag(b"PCrv")], vec![(tag(b"Crvs"), curve.clone()), (tag(b"ComO"), F::Enum(2, 0))])]))],
));
let run = F::Def(
next(),
vec![tag(b"GAtt")],
vec![(tag(b"Doub"), F::Raw(vec![0x8a, b'b', b'u', b'o', b'D', 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x28, 0x40]))],
);
let block = F::Def(
next(),
vec![tag(b"StBl")],
vec![
(tag(b"Glyp"), F::Obj(tag(b"GStr"), vec![(tag(b"Utf8"), F::Str("Hi\u{2029}there\0".into()))])),
(
tag(b"GAtt"),
F::Obj(tag(b"GlAS"), vec![(tag(b"Runs"), F::Objs(tag(b"GlAR"), vec![vec![(tag(b"Indx"), F::I32(9)), (tag(b"Item"), run)]]))]),
),
],
);
kids.push(F::Def(
next(),
vec![tag(b"TxtA")],
vec![
(tag(b"StSt"), F::Def(next(), vec![tag(b"Stry")], vec![(tag(b"Blok"), F::Shared(vec![block]))])),
(
tag(b"TxtH"),
F::Def(next(), vec![tag(b"ArFr")], vec![(tag(b"FrmB"), F::F64s(vec![0.0, -10.0, 30.0, 0.0])), (tag(b"ArtV"), F::F64(10.0))]),
),
],
));
let board = F::Def(
next(),
vec![tag(b"ShpN")],
vec![
(tag(b"ABEn"), F::Bool(true)),
(tag(b"Shpe"), F::Def(next(), vec![tag(b"ShNR")], vec![])),
(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 200.0, 100.0])),
(tag(b"Chld"), F::Shared(kids)),
],
);
synth::stream(&[
(tag(b"UVCn"), F::Obj(tag(b"UVCn"), vec![(tag(b"UPPI"), F::F64(96.0))])),
(
tag(b"DocR"),
F::Def(
1,
vec![tag(b"DocN")],
vec![(tag(b"Chld"), F::Shared(vec![F::Def(2, vec![tag(b"Sprd")], vec![(tag(b"Chld"), F::Shared(vec![board]))])]))],
),
),
])
}
fn affinity_native(stream: &[u8], method: vectorcraft_affinity::synth::Method) -> Vec<u8> {
vectorcraft_affinity::synth::container(&[("doc.dat", stream, method)], None)
}
#[test]
fn the_native_affinity_sample_reaches_every_reader_path() {
let bytes = affinity_native(&affinity_native_stream(), vectorcraft_affinity::synth::Method::Zstd);
let l = vectorcraft_engine::cmd::fileio::load("x.af", &bytes).unwrap();
assert!(!l.preview_only, "{:?}", l.warnings);
let kinds = l.doc.layers.iter().flat_map(|l| l.children().into_iter().flatten()).count();
assert!(kinds > 0);
let all = format!("{:?}", l.doc.layers);
for want in ["Path", "Text", "Compound"].iter().take(2) {
assert!(all.contains(want), "{want} missing");
}
assert_eq!(l.doc.artboards.len(), 1);
}
proptest! {
#![proptest_config(config())]
#[test]
fn affinity_native_documents_with_mutated_streams_render_and_export(
cut in 0usize..4096,
edits in prop::collection::vec((0usize..4096, any::<u8>()), 0..24),
) {
let mut stream = affinity_native_stream();
let n = stream.len();
for (at, b) in edits { stream[at % n] = b; }
stream.truncate(cut.max(16));
let bytes = affinity_native(&stream, vectorcraft_affinity::synth::Method::Stored);
survive("Affinity native stream", || vectorcraft_engine::cmd::fileio::load("x.af", &bytes).ok().map(|l| l.doc))?;
}
#[test]
fn affinity_native_containers_with_mutated_bytes_render_and_export(
edits in prop::collection::vec((0usize..8192, any::<u8>()), 1..16),
method in 0u8..3,
) {
use vectorcraft_affinity::synth::Method;
let m = [Method::Stored, Method::Zlib, Method::Zstd][usize::from(method)];
let mut bytes = affinity_native(&affinity_native_stream(), m);
let n = bytes.len();
for (at, b) in edits { bytes[at % n] = b; }
survive("Affinity native container", || vectorcraft_engine::cmd::fileio::load("x.af", &bytes).ok().map(|l| l.doc))?;
}
#[test]
fn affinity_garbage_never_panics(tail in prop::collection::vec(any::<u8>(), 0..2048)) {
let mut bytes = vectorcraft_affinity::MAGIC.to_vec();
bytes.extend(tail);
survive("Affinity garbage", || vectorcraft_engine::cmd::fileio::load("x.af", &bytes).ok().map(|l| l.doc))?;
}
#[test]
fn affinity_mutated_previews_render_and_export_without_panicking(
cut in 0usize..400,
edits in prop::collection::vec((0usize..400, any::<u8>()), 0..16),
) {
let mut bytes = affinity_preview_sample();
for (at, b) in edits {
if let Some(byte) = bytes.get_mut(at) { *byte = b; }
}
bytes.truncate(cut);
survive("Affinity preview", || vectorcraft_engine::cmd::fileio::load("x.af", &bytes).ok().map(|l| l.doc))?;
}
#[test]
fn affinity_placement_is_rejected_without_panicking(tail in prop::collection::vec(any::<u8>(), 0..2048)) {
let mut bytes = vectorcraft_affinity::MAGIC.to_vec();
bytes.extend(tail);
let r = catch_quiet(|| {
let mut s = vectorcraft_engine::Session::new();
s.execute("file.new", &json!({"width":100,"height":100})).unwrap();
let p = json!({"name":"renamed.png", "dataBase64":vectorcraft_format::base64_encode(&bytes)});
for cmd in ["file.place", "file.place.info"] { assert!(s.execute(cmd, &p).is_err()); }
assert!(s.execute("file.place.queue", &json!({"files":[p]})).is_err());
});
prop_assert!(r.is_ok(), "Affinity placement panicked: {:?}", r.err());
}
}
/// Import must not panic; whatever comes back must render and export without panicking either.
fn survive(what: &str, import: impl FnOnce() -> Option<Document>) -> Result<(), TestCaseError> {
let r = catch_quiet(|| {
@@ -529,12 +768,21 @@ fn saved(cmd: &str, params: Value) -> String {
rich_session().execute(cmd, &params).unwrap()["data"].as_str().unwrap().to_string()
}
/// `r`, after failing the case if the command panicked. The engine's guard reports a panic as
/// `EngineError::Internal`.
fn no_panic(r: Result<Value, vectorcraft_engine::EngineError>) -> Result<Value, vectorcraft_engine::EngineError> {
if let Err(vectorcraft_engine::EngineError::Internal { cmd, msg }) = &r {
panic!("{cmd} panicked: {msg}");
}
r
}
/// Library file `data` loaded with `load`; what it holds is then used on the rich document
/// (`then`, given the load's result), which renders and exports.
fn survive_library(what: &str, load: &str, data: &str, then: impl FnOnce(&mut vectorcraft_engine::Session, &Value)) -> Result<(), TestCaseError> {
survive(what, || {
let mut s = rich_session();
let r = s.execute(load, &json!({"data": data, "name": "fuzz"})).ok()?;
let r = no_panic(s.execute(load, &json!({"data": data, "name": "fuzz"}))).ok()?;
then(&mut s, &r);
Some((*s.doc().ok()?.doc).clone())
})
@@ -542,27 +790,55 @@ fn survive_library(what: &str, load: &str, data: &str, then: impl FnOnce(&mut ve
fn swatches(what: &str, data: &str) -> Result<(), TestCaseError> {
survive_library(what, "swatch.library.load", data, |s, r| {
let _ = s.execute("swatch.library.add", &json!({"library": r["library"], "apply": "fill"}));
let _ = no_panic(s.execute("swatch.library.add", &json!({"library": r["library"], "apply": "fill"})));
})
}
fn styles(what: &str, data: &str) -> Result<(), TestCaseError> {
survive_library(what, "graphicStyle.loadLibrary", data, |s, r| {
let _ = s.execute("select.all", &json!({}));
let _ = s.execute("graphicStyle.addFromLibrary", &json!({"library": r["library"], "apply": true}));
let _ = no_panic(s.execute("select.all", &json!({})));
let _ = no_panic(s.execute("graphicStyle.addFromLibrary", &json!({"library": r["library"], "apply": true})));
})
}
fn flattener_presets(what: &str, data: &str) -> Result<(), TestCaseError> {
survive_library(what, "flattener.presets.import", data, |s, r| {
let _ = s.execute("select.all", &json!({}));
let _ = no_panic(s.execute("select.all", &json!({})));
if let Some(name) = r["imported"].get(0) {
let _ = s.execute("flattener.preview", &json!({"preset": name, "highlight": "allAffected"}));
let _ = s.execute("object.flattenTransparency", &json!({"preset": name, "lineArtPpi": 36, "gradientPpi": 36}));
let _ = no_panic(s.execute("flattener.preview", &json!({"preset": name, "highlight": "allAffected"})));
let _ = no_panic(s.execute("object.flattenTransparency", &json!({"preset": name, "lineArtPpi": 36, "gradientPpi": 36})));
}
})
}
/// Swatch exchange (`.ase`) bytes loaded as a swatch library, then added to the rich document.
fn swatch_exchange(what: &str, bytes: &[u8]) -> Result<(), TestCaseError> {
survive(what, || {
let mut s = rich_session();
let load = json!({"dataBase64": vectorcraft_format::base64_encode(bytes), "name": "fuzz.ase"});
let r = no_panic(s.execute("swatch.library.load", &load)).ok()?;
let _ = no_panic(s.execute("swatch.library.add", &json!({"library": r["library"], "apply": "fill"})));
Some((*s.doc().ok()?.doc).clone())
})
}
/// A random swatch exchange block (type, body). The body holds a name, a color model when the block
/// is a color, and random bytes for the rest.
fn arb_ase_block() -> impl Strategy<Value = (u16, Vec<u8>)> {
let kind = prop::sample::select(vec![0xC001u16, 0xC002, 0x0001, 0x0042]);
let model = prop::sample::select(vec![*b"RGB ", *b"CMYK", *b"LAB ", *b"Gray", *b"HSB "]);
(kind, "[ -~]{0,8}", model, prop::collection::vec(any::<u8>(), 0..24)).prop_map(|(kind, name, model, rest)| {
let units: Vec<u16> = name.encode_utf16().chain([0]).collect();
let mut body = (units.len() as u16).to_be_bytes().to_vec();
body.extend(units.iter().flat_map(|u| u.to_be_bytes()));
if kind == 0x0001 {
body.extend(model);
}
body.extend(rest);
(kind, body)
})
}
/// Characters that break JSON and GPL palettes.
fn arb_edit() -> impl Strategy<Value = (usize, char)> {
(0usize..20_000, prop::sample::select(vec!['{', '}', '[', ']', '"', ':', ',', '-', '9', 'e', '.', ' ', '\n', '#', 'n', 'x', '\t']))
@@ -596,6 +872,32 @@ proptest! {
let text = saved("flattener.presets.export", json!({"names": ["high", "medium", "low"]}));
flattener_presets("mutated flattener presets", &mutate_text(&text, cut, &edits))?;
}
#[test]
fn swatch_exchange_garbage_never_panics(blocks in prop::collection::vec(arb_ase_block(), 0..12), tail in prop::collection::vec(any::<u8>(), 0..16)) {
let mut bytes = b"ASEF\0\x01\0\0".to_vec();
bytes.extend((blocks.len() as u32).to_be_bytes());
for (kind, body) in &blocks {
bytes.extend(kind.to_be_bytes());
bytes.extend((body.len() as u32).to_be_bytes());
bytes.extend(body);
}
bytes.extend(tail);
swatch_exchange("swatch exchange garbage", &bytes)?;
}
#[test]
fn mutated_swatch_exchange_files_never_panic(cut in prop::option::of(0usize..200), edits in prop::collection::vec((0usize..200, any::<u8>()), 0..12)) {
let mut bytes = vectorcraft_testkit::ase::sample();
for (at, b) in edits {
let n = bytes.len();
bytes[at % n] = b;
}
if let Some(cut) = cut {
bytes.truncate(cut);
}
swatch_exchange("mutated swatch exchange file", &bytes)?;
}
}
/// A small image of each raster format Place reads, with resolution metadata where it has some.
@@ -704,8 +1006,8 @@ proptest! {
fn pdf_presets(what: &str, data: &str) -> Result<(), TestCaseError> {
survive_library(what, "pdf.preset.import", data, |s, r| {
for name in r["imported"].as_array().into_iter().flatten() {
let _ = s.execute("document.exportPdf", &json!({"preset": name}));
let _ = s.execute("document.save", &json!({"format": "ai", "preset": name}));
let _ = no_panic(s.execute("document.exportPdf", &json!({"preset": name})));
let _ = no_panic(s.execute("document.save", &json!({"format": "ai", "preset": name})));
}
})
}
@@ -1219,14 +1521,14 @@ proptest! {
/// each one imported.
fn print_presets(what: &str, data: &str) -> Result<(), TestCaseError> {
survive_library(what, "print.presets.import", data, |s, r| {
let list = s.execute("print.presets.list", &json!({})).unwrap_or_default();
let list = no_panic(s.execute("print.presets.list", &json!({}))).unwrap_or_default();
for name in r["imported"].as_array().into_iter().flatten() {
let Some(p) = list["presets"].as_array().into_iter().flatten().find(|p| &p["name"] == name) else { continue };
let settings = json!({ "settings": p["settings"] });
let pages = s.execute("print.preview", &settings).ok().and_then(|v| v["pages"].as_u64());
let _ = s.execute("print.setup", &settings);
let pages = no_panic(s.execute("print.preview", &settings)).ok().and_then(|v| v["pages"].as_u64());
let _ = no_panic(s.execute("print.setup", &settings));
if pages.is_some_and(|n| n <= 8) {
let _ = s.execute("file.print", &settings);
let _ = no_panic(s.execute("file.print", &settings));
}
}
})
@@ -1256,9 +1558,11 @@ proptest! {
fn perspective_presets(what: &str, data: &str) -> Result<(), TestCaseError> {
survive_library(what, "perspective.presets.import", data, |s, r| {
for name in r["imported"].as_array().into_iter().flatten() {
let _ = s.execute("perspective.grid.preset", &json!({"name": name}));
let _ = s.execute("perspective.grid.define", &json!({"name": name, "gridline": 3}));
let _ = s.execute("perspective.draw", &json!({"command": "shape.rectangle", "params": {"x": 300, "y": 400, "width": 40, "height": 30}}));
let _ = no_panic(s.execute("perspective.grid.preset", &json!({"name": name})));
let _ = no_panic(s.execute("perspective.grid.define", &json!({"name": name, "gridline": 3})));
let _ = no_panic(
s.execute("perspective.draw", &json!({"command": "shape.rectangle", "params": {"x": 300, "y": 400, "width": 40, "height": 30}})),
);
}
})
}
+139
View File
@@ -0,0 +1,139 @@
//! Inline graphics in text drawn on the canvas and written to SVG and PDF: a symbol (a red
//! circle drawn in code) set inline in a line of type.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use serde_json::{Value, json};
use vectorcraft_doc::{Document, NodeId, NodeKind, TextObject};
use vectorcraft_geom::Rect;
use vectorcraft_testkit::fixtures::{self, exec, id_of};
use vectorcraft_testkit::raster;
/// A session with a red circle symbol "Dot" (its instance deleted) and the point type
/// "Tap now" (20 pt, baseline at y = 100) with the symbol inline after "Tap ".
fn setup(inline: bool) -> (vectorcraft_engine::Session, NodeId) {
let mut s = fixtures::session_with(400.0, 300.0);
fixtures::ellipse(&mut s, 300.0, 250.0, 20.0, 20.0);
exec(&mut s, "paint.setFill", json!({"color": "#ff0000"}));
exec(&mut s, "paint.setStroke", json!({"none": true}));
exec(&mut s, "symbol.new", json!({"name": "Dot"}));
exec(&mut s, "edit.clear", json!({}));
// Type is painted with the current fill: black, not the circle's red.
exec(&mut s, "paint.setFill", json!({"color": "#000000"}));
let id = id_of(&exec(&mut s, "text.create", json!({"x": 20, "y": 100, "text": "Tap now", "size": 20})));
if inline {
exec(&mut s, "text.insertInline", json!({"id": id.0, "at": 4, "symbol": "Dot"}));
}
exec(&mut s, "select.none", json!({}));
(s, id)
}
fn doc(s: &vectorcraft_engine::Session) -> Document {
(*s.doc().unwrap().doc).clone()
}
fn text(d: &Document, id: NodeId) -> TextObject {
match &d.node(id).unwrap().kind {
NodeKind::Text(t) => (**t).clone(),
_ => panic!("not text"),
}
}
/// Where the inline art lands, in document space.
fn art_rect(d: &Document, id: NodeId) -> Rect {
let t = text(d, id);
let l = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
assert_eq!(l.inlines.len(), 1);
t.xf.transform_rect_bbox(l.inlines[0].bounds)
}
fn red(p: [u8; 3]) -> bool {
p[0] > 200 && p[1] < 80 && p[2] < 80
}
#[test]
fn canvas_draws_the_symbol_where_the_layout_puts_it() {
let (s, id) = setup(true);
let d = doc(&s);
let r = art_rect(&d, id);
// 20 pt tall at 20 pt type, on the pen after "Tap ", centred on the cap-height middle.
assert!((r.height() - 20.0).abs() < 0.5 && (r.width() - 20.0).abs() < 0.5, "{r:?}");
assert!(r.x0 > 50.0 && r.x0 < 70.0, "{r:?}");
assert!(r.center().y < 100.0 && r.center().y > 90.0, "{r:?}");
let img = raster::render_region(&d, Rect::new(0.0, 0.0, 200.0, 150.0), 2.0);
let px = |x: f64, y: f64| img.over_white((x * 2.0) as u32, (y * 2.0) as u32);
assert!(red(px(r.center().x, r.center().y)), "{:?} at {:?}", px(r.center().x, r.center().y), r.center());
// Only there: just outside the circle, no red.
assert!(!red(px(r.x0 - 3.0, r.center().y)) && !red(px(r.center().x, r.y0 - 3.0)));
// Without the graphic nothing is red, and the text after it sits further left.
let (plain, pid) = setup(false);
let pd = doc(&plain);
let img = raster::render_region(&pd, Rect::new(0.0, 0.0, 200.0, 150.0), 2.0);
assert_eq!(img.count(|p| p[0] > 200 && p[1] < 80 && p[2] < 80 && p[3] > 200), 0);
let w = text(&d, id).bounds().unwrap().width() - text(&pd, pid).bounds().unwrap().width();
assert!((w - 20.0).abs() < 0.5, "{w}");
}
#[test]
fn canvas_applies_the_text_objects_opacity_to_its_inline_art() {
let (mut s, id) = setup(true);
exec(&mut s, "select.set", json!({"ids": [id.0]}));
exec(&mut s, "transparency.set", json!({"opacity": 50}));
let d = doc(&s);
let r = art_rect(&d, id);
let img = raster::render_region(&d, Rect::new(0.0, 0.0, 200.0, 150.0), 2.0);
let p = img.over_white((r.center().x * 2.0) as u32, (r.center().y * 2.0) as u32);
// Half red over white: pink.
assert!(p[0] > 240 && (100..180).contains(&p[1]) && (100..180).contains(&p[2]), "{p:?}");
}
fn svg_of(s: &mut vectorcraft_engine::Session, params: Value) -> String {
let v = exec(s, "document.serialize", params);
v["text"]
.as_str()
.map(str::to_string)
.unwrap_or_else(|| String::from_utf8(vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()).unwrap())
}
#[test]
fn svg_keeps_live_text_and_draws_the_symbol() {
let (mut s, id) = setup(true);
let svg = svg_of(&mut s, json!({"format": "svg", "outlineText": false}));
// Live text without the object replacement character; the text after the graphic is placed
// after it.
assert!(svg.contains("<text") && svg.contains(">Tap") && svg.contains("now<"), "{svg}");
assert!(!svg.contains('\u{FFFC}'), "{svg}");
// The symbol's art: a <symbol> def and a <use> of it.
assert!(svg.contains("<symbol id=\"Dot\""), "{svg}");
assert!(svg.matches("xlink:href=\"#Dot\"").count() == 1, "{svg}");
// Re-imported, the art is a red circle where the canvas draws it.
let back = vectorcraft_svg::import(&svg).unwrap();
let img = raster::render_region(&back, Rect::new(0.0, 0.0, 200.0, 150.0), 2.0);
let r = art_rect(&doc(&s), id);
let p = img.over_white((r.center().x * 2.0) as u32, (r.center().y * 2.0) as u32);
assert!(red(p), "{p:?}");
// Text as outlines and fewer tspans still draw it.
for params in [json!({"format": "svg", "outlineText": true}), json!({"format": "svg", "outlineText": false, "fewerTspans": true})] {
let svg = svg_of(&mut s, params.clone());
assert!(svg.contains("xlink:href=\"#Dot\""), "{params}: {svg}");
assert!(!svg.contains('\u{FFFC}'), "{params}");
}
}
#[test]
fn pdf_draws_the_symbol_art() {
let (mut s, id) = setup(true);
let v = exec(&mut s, "document.serialize", json!({"format": "pdf"}));
let bytes = vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap();
let back = vectorcraft_pdf::import(&bytes).unwrap();
let r = art_rect(&doc(&s), id);
let img = raster::render_region(&back, Rect::new(0.0, 0.0, 200.0, 150.0), 2.0);
let p = img.over_white((r.center().x * 2.0) as u32, (r.center().y * 2.0) as u32);
assert!(red(p), "{p:?} at {:?}", r.center());
// Without it, nothing red.
let (mut plain, _) = setup(false);
let v = exec(&mut plain, "document.serialize", json!({"format": "pdf"}));
let back = vectorcraft_pdf::import(&vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()).unwrap();
let img = raster::render_region(&back, Rect::new(0.0, 0.0, 200.0, 150.0), 2.0);
assert_eq!(img.count(|p| p[0] > 200 && p[1] < 80 && p[2] < 80 && p[3] > 200), 0);
}
+10
View File
@@ -36,3 +36,13 @@ 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");
}
+5 -1
View File
@@ -20,12 +20,16 @@ const DEFAULT_FONT: &str = "Helvetica";
/// 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);
Loaded 100 of 207 files, more files were not shown because too many files have changed in this diff. Show more