Compare commits

...
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 8124cf7b43 Clippy 1.99: as_chunks in the Affinity raster tests
#763 landed beside #771 with five chunks_exact(4) calls in
crates/affinity/tests/raster.rs, which clippy 1.99's chunks_exact_to_as_chunks
rejects, so cargo xtask ci failed at clippy on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:15:36 +07:00
BFlat d168d4f843 Merge pull request #771 from DisasterAreaDesigns/affinity-as-chunks
Clippy 1.99: use as_chunks for constant-size chunks
2026-10-09 22:32:02 +07:00
BFlat 9ff0277306 Merge pull request #763 from callmeBalloch/feat/affinity-af-validation
AF.4: harden current .af artboards, text and images with a pinned corpus
2026-10-09 22:21:20 +07:00
bflatasticandClaude Opus 5.5 573f27e6c9 AF.4 docs: leave out shop listings and sandbox notes
The source audit linked commercial template listings and other pages it
didn't use, and described the environment it ran in. Keep the two sources
the corpus uses, say in a sentence what kinds of sources were left out and
why, and state plainly that reopening our output in Affinity is still to do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:20:39 +07:00
BFlat 0a91fd9b44 Merge pull request #762 from DisasterAreaDesigns/suitcase-fonts
Fonts: find suitcase fonts, whose font data is in the resource fork
2026-10-09 21:49:07 +07:00
Matthew FarrowandClaude Sonnet 5.5 75151bd79a Clippy 1.99: use as_chunks for constant-size chunks, and say the gate targets the latest stable
Clippy 1.99.0 added `chunks_exact_to_as_chunks`, which fails `cargo xtask ci`
at its clippy step. Eight uses of `chunks_exact(_mut)` with a constant size now
use `as_chunks(_mut)` (stable since 1.88, within the 1.95 minimum): four in
crates/affinity, three in crates/effects and crates/engine tests, and two in
xtask's sha256. docs/development.md says the gate targets the current stable.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 10:43:23 -04:00
bflatasticandClaude Opus 5.5 be9a879e51 Fonts: cap the total a suitcase fork can make the scan copy; no fork look-up off macOS
References can share data, so a damaged fork could make the scan copy one
large resource up to 256 times: cap the total copied like the fork itself.
Off macOS no file has a fork, so the scan doesn't look at each font file twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:40:42 +07:00
BFlat 16dcbc6138 Merge pull request #769 from storytold/fix/740-move-snaps-to-grid
Fix #740: moved art snaps to the grid
2026-10-09 21:37:23 +07:00
bflatasticandClaude Opus 5.5 7dd69aaf81 Fix #740: moved art snaps to the grid
With View › Snap to Grid on, a selection moved with the Selection, Group
Selection or Direct Selection tool lands on the grid: on each axis,
whichever of its two edges and its centre is nearest a gridline (every
subdivision, 9 pt by default) moves onto it. Snap to Grid takes over from
Smart Guides and Snap to Point while it is on, as it does for drawn points.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:36:42 +07:00
Matthew FarrowandClaude Sonnet 5.5 d35db792dc PDF tests: the system-fonts test checks the first installed family that stays live text
The test took the first installed family that isn't bundled and expected its
PDF text to stay live. The PDF doesn't embed the font, so the page draws a
stand-in and text stays live only where the installed font's glyphs match it.
With suitcase fonts now found, the first family on a Mac is a condensed
display face whose glyphs don't, so the importer rightly kept outlines and the
test failed. Which family comes first depends on the machine's fonts: the test
now tries up to 200 families and checks the first that stays live.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 10:29:11 -04:00
Matthew FarrowandClaude Sonnet 5.5 e1d35131d3 Fonts: find suitcase fonts, whose font data is in the resource fork
macOS lists fonts such as the ones Microsoft Office installs in
/Library/Fonts/Microsoft (Bookman Old Style, Impact, Georgia, Tahoma, ...) but
their files have nothing in the data fork and no font extension: the fonts are
`sfnt` resources in the resource fork. The font scan only read data forks of
.ttf/.otf/.ttc/.otc files, so those families were never cataloged and a file
using one (an EPS naming BookmanOldStyle-Bold) showed the font as missing.

The scan now catalogs a file with an empty data fork and a resource fork by
the fonts in that fork, and loading reads them the same way. The fork reader
(crates/text/src/suitcase.rs) follows Apple's published resource file format,
caps what a damaged fork can make it read and never panics. macOS only.

Tests: the fork reader on a synthetic fork built around a bundled font,
including truncated and garbage forks; a macOS scan of a suitcase file.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 10:05:18 -04:00
BFlat aa548d2bef Merge pull request #760 from storytold/feat/windows-setup-wizard
Packaging: Windows MSI setup wizard (folder choice, progress, version, launch on finish)
2026-10-09 20:56:48 +07:00
Riccardo Balloch 79c5d673ff AF.4: validate and harden Affinity 3 artboards, text and images 2026-10-09 13:45:55 +00:00
BFlat f837c9305a Merge pull request #759 from jmverges/ai-import-draw-page-once
Part of #758: .ai import draws the page once when weighing the layers
2026-10-09 20:44:56 +07:00
BFlatandClaude Opus 5.5 f95e320907 Packaging: Windows MSI gets a setup wizard with version and launch box
Double-clicking the MSI used to flash a progress bar and close, so users couldn't tell it had
installed. It now opens a wizard: Welcome, install folder (remembered across upgrades), Ready,
progress, and a Finish page that says where to find the app with a ticked "Launch VectorCraft"
box. Every page names the full version, pre-release tag included. The wizard art is drawn from
the app icon at package time; msiexec /qn still installs silently.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:41:53 +07:00
Juan Manuel Verges SolanasandClaude Opus 5.5 1c692e5191 Part of #758: .ai import draws the page once when weighing the layers against it
Each comparison of the layers' art with the page made a new renderer and
drew both again, so the page was drawn and every image decoded once per
text object weighed, plus once more for the final check. The page is now
drawn once, and the layers keep one renderer (with its decoded images)
across their draws. The page's images keep a renderer of their own, as
their keys can match the layers' without their pixels doing so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:34:06 +00:00
BFlat a89426c2f6 Merge pull request #756 from storytold/feat/745-libraries
Fix #745: a working Libraries panel of graphics, colours and text styles
2026-10-09 20:24:33 +07:00
47 changed files with 2801 additions and 191 deletions

No files matched your search

+37
View File
@@ -0,0 +1,37 @@
name: Affinity 3 corpus
on:
push:
branches: [main]
paths: ["crates/**", "xtask/**", "Cargo.toml", "Cargo.lock", ".github/workflows/affinity-corpus.yml"]
pull_request:
paths: ["crates/**", "xtask/**", "Cargo.toml", "Cargo.lock", ".github/workflows/affinity-corpus.yml"]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: affinity-corpus-${{ github.ref }}
cancel-in-progress: true
jobs:
affinity3:
runs-on: ubuntu-latest
timeout-minutes: 30
env:
AFFINITY_CORPUS_REQUIRED: "1"
CARGO_PROFILE_DEV_DEBUG: "0"
CARGO_PROFILE_TEST_DEBUG: "0"
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
toolchain: "1.95"
- name: Fetch pinned and checksum-verified corpus
run: cargo xtask corpus --affinity
- name: Parse every file and freeze actual Affinity 3 feature retention
run: cargo test -p vectorcraft-affinity --all-features -- --nocapture
- name: Check every artboard, native save, SVG, PDF and PSD exports
run: cargo test -p vectorcraft-engine --test affinity_artboards --test affinity_text --test affinity_corpus -- --nocapture
+1 -1
View File
@@ -46,7 +46,7 @@ jobs:
}
- name: XML, desktop entry, AppStream, Flatpak manifest
run: |
xmllint --noout packaging/windows/vectorcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml*
xmllint --noout packaging/windows/vectorcraft.wxs packaging/windows/vectorcraft.en-us.wxl packaging/macos/*.plist* packaging/linux/*.xml*
# Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon.
if grep -oE '<Icon Id="[^"]*"' packaging/windows/vectorcraft.wxs | grep -vE '\.(ico|exe)"$'; then
echo "::error::Icon Id must end in .ico or .exe"; exit 1
+3 -1
View File
@@ -150,7 +150,9 @@ jobs:
run: cargo xtask version set "$VECTORCRAFT_VERSION"
- name: Install WiX
shell: pwsh
run: dotnet tool install --global wix --version 5.0.2
run: |
dotnet tool install --global wix --version 5.0.2
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
- name: Build, sign, package
shell: pwsh
env:
+3 -1
View File
@@ -45,7 +45,9 @@ jobs:
key: windows-arm64-package
- name: Install WiX
shell: pwsh
run: dotnet tool install --global wix --version 5.0.2
run: |
dotnet tool install --global wix --version 5.0.2
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
- name: Build and package (unsigned)
shell: pwsh
env:
Generated
+1
View File
@@ -4341,6 +4341,7 @@ version = "0.7.0"
dependencies = [
"crc32fast",
"flate2",
"image",
"proptest",
"ruzstd",
]
+4 -2
View File
@@ -190,7 +190,7 @@ For the experimental, unsupported 64-bit Windows 7 build, see [Windows 7 instruc
VectorCraft is under active development. [**ROADMAP.md**](ROADMAP.md) covers what ships today, the
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
**Where we are (2026-10-09):** 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).
@@ -198,7 +198,9 @@ Affinity documents (`.af` from Affinity 3, `.afdesign`, `.afpub` and, by their c
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).
doesn't write Affinity files. Current `.af` validation includes 33 pinned files, native save/reload
and every-board SVG/PDF/PSD export, with fixes for Affinity 3 artboards, source-backed JPEGs and text runs.
[Scope and limits](crates/affinity/README.md); [source audit and remaining gaps](docs/affinity-validation.md).
The interface
speaks English, Japanese, Traditional and Simplified Chinese, Spanish, French, Italian, Russian and Ukrainian (and Czech and Brazilian Portuguese in the menus).
The scores are
+7 -7
View File
@@ -38,7 +38,7 @@ since 2026-10-01.
| **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~4 of ~56 filters), SVG Filters, CJK composition (vertical type with kinsoku, tate-chu-yoko and the font's vertical metrics; ruby, mojikumi and proportional vertical metrics open), Variables (data merge), scripting |
| **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first |
| **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured |
| **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai`, EPS, DXF, EMF/WMF, raster formats, PSD export, Place and Links, Package, Print, clipboard flavours. Affinity documents (Affinity 1–3) open natively as editable art, checked against Affinity's own thumbnails of 22 pinned public files; Affinity export is missing and effects, adjustments and brushes don't import. Affinity isn't an Illustrator feature, so this doesn't raise the parity score. The editing copy inside an Illustrator EPS or `.ai` is read for its structure: layers, groups, names, hidden objects and type, colours, gradients, transparency, images and every artboard (see Out of scope for what it doesn't read yet); DWG has no open spec |
| **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 51 pinned public files (33 current `.af`), with selected saved-thumbnail comparisons and every-board native/SVG/PDF/PSD checks for current files; Affinity export is missing and effects, adjustments and brushes don't import. Affinity isn't an Illustrator feature, so this doesn't raise the parity score. The editing copy inside an Illustrator EPS or `.ai` is read for its structure: layers, groups, names, hidden objects and type, colours, gradients, transparency, images and every artboard (see Out of scope for what it doesn't read yet); DWG has no open spec |
| **Bundled content:** brush, symbol, style and swatch libraries | ~30% | Ours are original and generated in code, and far fewer than Illustrator ships; brush and symbol libraries are still missing |
| **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine |
| **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA |
@@ -74,8 +74,8 @@ Ordered by how much each gap blocks someone from switching. Sizes are one-agent
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, 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
(51 pinned files now include 28 original Affinity 3.2.3 feature probes; large interacting documents,
complex typography and independently paired `.af`/PSD renders reopened in Affinity remain 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
@@ -140,7 +140,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **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. 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.
- **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; with View › Snap to Grid on, a moved selection lands on the grid instead, by whichever of its edges or its centre is nearest a gridline on each axis, #740; 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.
@@ -166,7 +166,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export. The desktop app writes its warnings and errors (graphics adapter, a lost graphics device, panics the guard recovered from) to standard error and to `logs/vectorcraft.log` next to the preferences, the two previous runs kept and each file capped at 16 MiB, so a launch without a terminal still leaves something to attach to a bug report (#465).
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420; hidden objects written hidden on Save and `convert`, and `data-*` attributes kept as the objects' own data: #522; top-level groups open as layers named from `data-name`, else the id, also when one more group (a transform on all the art, a `<switch>`) wraps them, hidden ones hidden, and a file whose art sits loose under a root `<svg>` with an id (or `data-name`), as one-layer exports write it, opens with its one layer named from it; nested groups stay groups, except the sublayers VectorCraft writes, marked `data-vc-layer`, and Inkscape sublayers, which open as sublayers in their layer's colour: #372); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; a type object's stroke written as each glyph's outline stroked back on the type, so it stays one editable object; lines wrapped in a frame (each wrapped line ending in a space, one left edge, leading and font) back as one area type object, left aligned or justified with its paragraph spacing, where laying it out breaks the lines where the file does; glyphs set apart by gaps wider than a space (a barcode's digit groups) keeping their places; layers stacked as the pages mark them even when one first paints on a later page, art ending a layer's marked content or a clip closing in a later layer kept in its own layer, and layers with no art kept empty: #508; masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320). A masked CMYK image (`/SMask`, `/Mask` or a colour key) keeps its inks too, its alpha coming in as a greyscale opacity mask.
- EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export. EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546).
- Affinity documents in (AF.1–AF.3): `.af` (Affinity 3) and `.afdesign`/`.afphoto`/`.afpub` (Affinity 1 and 2, container versions 8–12) open and place as editable art: layers and sublayers, groups (pass-through or isolated), artboards (with their names and backgrounds) and Publisher pages side by side, curves with live corners, parametric shapes (rectangles with corner radii, ellipses, polygons, stars, pies, triangles, trapezoids), compound shapes, solid/linear/elliptical gradient fills and strokes (weight, dashes, alignment, scale-with-object), clipping by nesting, vector and pixel masks, artistic and frame text with fonts, sizes, tracking, leading and alignment, placed images (their original JPEG/PNG) and pixel layers (8/16-bit RGB, CMYK), opacity, visibility, locking and blend modes, at the document's resolution in points. Effects, adjustments, brushes and pressure profiles, transparency gradients, master pages, symbol instances (imported as their cached picture), special shapes (cloud, heart, cog…, imported as ellipses) and Affinity-only blend modes are named in the import warning. A file whose native data can't be read opens as its embedded preview with the reason, and that preview can't be placed. Save never writes back over the Affinity file and there is no Affinity export. 22 public CC0/MIT/Apache-2.0 files, pinned and sha256-verified (`cargo xtask corpus --affinity`), must render within a few levels of the thumbnails Affinity saved in them; [scope, provenance and limits](crates/affinity/README.md).
- Affinity documents in (AF.1–AF.4): `.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. AF.4 adds Affinity 3 artboard recognition and actual-outline clipping, source-backed JPEG/PNG raster tiles, Unicode text-run recovery, stored fallback/exact installed fonts, OpenType feature flags and compound-geometry limits. 51 public CC0/MIT/Apache-2.0 files (33 current `.af`), pinned and sha256-verified (`cargo xtask corpus --affinity`), test native retention and every-board native/SVG/PDF/PSD output for current files; selected files compare their saved thumbnails. No Affinity reopen or independent paired PSD oracle was available; [source audit and complex-font gaps](docs/affinity-validation.md); [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.
@@ -181,7 +181,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
| M1 | Selection, transform, layers, MCP (common command tools, strict keys, session resources and version-aware result hints) | ✅ mostly done (transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending) | 4–6 |
| M2 | Drawing tools + smart guides | ✅ mostly done (Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; construction widget/refinements pending) | 12–18 |
| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork; gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool, Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries. Left: freeform and mixed spot/process gradients export as stops or process colours to SVG/PDF; confirm the Scale Strokes & Effects default | 4–8 |
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info; AF.1–AF.3 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish | 3–6 (existing scope; Affinity export unestimated) |
| 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.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation). 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 (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 |
@@ -225,7 +225,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| Image Trace, graphs, image tools | 3 | 70% | graph Design/Column/Marker, Create Object Mosaic, Crop Image polish (an on-canvas crop box), per-feature stroke/fill choice and varying widths in Image Trace's Create Strokes (Vector Halftone and the Image Trace View modes, Palette and Create Strokes are done) | 6–10 |
| Layers, artboards, document setup | 5 | 82% | Layers panel: a confirmation before deleting layers that hold art, Collect for Export, row context menus, the Layer Options dialog for several rows with mixed values, Paste Remembers Layers for sublayers, drag-scrolling a long panel while dragging rows; artboard preset polish (Rearrange All Artboards, Document Setup, New Document and the rest of the Layers panel are done) | 4–7 |
| View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, Snap to Pixel/Glyph (print tiling is done) | 9–14 |
| Guides, grids, smart guides, snapping, rulers | 3 | 85% | global/video rulers, Spacing Guides' distance readouts and spacing for resized or drawn art, Smart Guides' angle and distance readouts while drawing, Alt swapping a ruler guide's orientation, ruler guides selected together with art (marquee, Shift-click), ruler guides as Layers rows with their own lock and hide (#451: Lock Guides locks them all; Make Guides paths are Layers rows already) (dragging existing guides is done, #414; new guides snapping to side midpoints and artboards, and artboard guides, #451; the drawing tools snapping, with Construction Guides, #506; Spacing Guides for moves, #394) | 2–4 |
| 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; moves snapping to the grid, #740) | 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 rest of Illustrator's editing data (area type and type on a path, appearances and live effects, symbols, pattern fills, brushes, placed files: #637), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers, presets and Affinity import are done | 6–10 (existing scope; Affinity export unestimated) |
| Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 |
| Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done) | 1–2 |
+1
View File
@@ -16,6 +16,7 @@ synth = []
[dependencies]
crc32fast = { workspace = true }
flate2 = { workspace = true }
image = { workspace = true }
ruzstd = "0.9"
[dev-dependencies]
+36 -13
View File
@@ -11,7 +11,7 @@ Affinity files.
| 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 |
| Artboards (legacy `ABEn` or Affinity 3 `phrp`/`aprp`), with name, paint and any transform | artboards plus a clip group with the actual vector outline, background and stroke |
| 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 |
@@ -23,9 +23,9 @@ Affinity files.
| 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 |
| Artistic and frame text: Unicode scalar-indexed runs, stored fallback font, family/weight/style, boolean OpenType features, all caps, 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 |
| Pixel layers (`Rstr`): RGBA 8/16-bit and CMYK 8-bit tiles, cropped to their content; source-backed RGBA8 JPEG/PNG tiles with matching dimensions and zero origin | 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 |
@@ -35,7 +35,7 @@ transparency gradients, fill opacity, bitmap fills, master pages, conical gradie
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
columns or a curved outline, paragraph indents/spacing, mixed paragraph alignments, unsupported font selectors, 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,
@@ -78,19 +78,32 @@ of CC0 sample files.
* **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`.
The reader also has an independent copy in 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).
* `cargo xtask corpus --affinity` fetches 51 public CC0/MIT/Apache-2.0 documents at pinned
commits, SHA-256 verified against `xtask/affinity-corpus.sha256`, with upstream license notices.
Of these, 33 are current `.af`: four CC0 vector illustrations and 28 original Affinity 3.2.3
feature probes plus one separately identified derived regression input from
[SethRobinson/Patchy](https://github.com/SethRobinson/Patchy). Only document data and provenance
notices were inspected, never that project's importer or authoring scripts.
* `tests/real_files.rs` parses every file and freezes the modeled features of all 33 current
`.af` files. Native raster tests compare decoded pixels with independent embedded images;
text tests assert Unicode run boundaries, fallback fonts and feature retention.
* `engine/tests/affinity_corpus.rs` requires editable native import, exact `.vectorcraft`
save/reload, and SVG/PDF/PSD export of every board in all 33 current files. It checks SVG/PDF
dimensions and reimport, and independently decodes PSD merged pixels using the published
specification. These are VectorCraft consistency checks, not independent Affinity PSD oracles.
Saved-thumbnail comparisons cover the existing 22 documents plus nine new representative
fixtures, checking every board; unsupported effects and text layout are not claimed visually
faithful. The Affinity corpus workflow requires the files, so missing fixtures cannot silently
pass CI. Run locally with `AFFINITY_CORPUS_REQUIRED=1` for the same strict gate.
* Synthetic engine regressions cover nested, rotated and curved artboards, negative origins,
multiple spreads and resolution conversion, alongside text recovery and native save/export.
* `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`):
@@ -100,6 +113,10 @@ The same reader is shared, as an independent copy, with PhotoCraft's `photocraft
cargo +nightly fuzz run stream -- -max_total_time=600
```
The source audit, licensing decisions and remaining complex-document/font cases are recorded in
[affinity-validation.md](../../docs/affinity-validation.md). Reopening VectorCraft's exports in
Affinity remains an independent validation step that hasn't been done.
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`
@@ -113,8 +130,14 @@ default (`Limits`), a 64 MiB zstd window, 4096 saved revisions, array lengths no
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
those 189 public documents uses 3.3 million), 384 levels of object nesting, 128 levels of layers, 500 000 layers, four million curve
nodes per curve, 64 megapixels per pixel layer (cropped to its content first) and 1024 gradient
stops. Every archive entry's CRC-32 and size must match. Malformed input returns an `Error`; the
nodes per curve; compound outlines also cap recursion at 128 levels, operand visits at 100 000
and cumulative geometry work at four million, rejecting cycles and partial results on limits.
Pie sweeps normalize finite angles in constant time. Pixel layers are limited to 64 megapixels
(cropped to their content first); gradients to 1024 stops. Text collection is bounded to a
million characters per node before allocation, with a cumulative 16 MiB budget for owned font
names, features and gradient stops. Exhaustion warns and uses default attributes for remaining
text, preserving its characters.
Every archive entry's CRC-32 and size must match. Malformed input returns an `Error`; the
crate has no panics outside tests.
## Privacy
+2 -2
View File
@@ -337,7 +337,7 @@ fn unpredict(b: &mut [u8], compression: u8) -> Result<(), Error> {
(0x40, _) => byte_delta(b),
(0x80, false) => {
let mut prev = 0u16;
for pair in b.chunks_exact_mut(2) {
for pair in b.as_chunks_mut::<2>().0.iter_mut() {
let v = u16::from_le_bytes([pair[0], pair[1]]).wrapping_add(prev);
pair.copy_from_slice(&v.to_le_bytes());
prev = v;
@@ -349,7 +349,7 @@ fn unpredict(b: &mut [u8], compression: u8) -> Result<(), Error> {
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() {
for (i, out) in b.as_chunks_mut::<4>().0.iter_mut().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"));
};
+88 -7
View File
@@ -6,15 +6,77 @@ use crate::stream::{ObjId, Tag, Value};
/// Nodes per curve object.
const MAX_NODES: usize = 4_000_000;
/// A shared-object stream is bounded, but a compound graph can expand exponentially. Count
/// each visit and each curve record / generated contour / transformed segment across the whole
/// outline, including repeated references. Match the document reader's nesting limit.
const MAX_OUTLINE_DEPTH: usize = 128;
const MAX_OUTLINE_VISITS: usize = 100_000;
struct OutlineBudget {
active: Vec<ObjId>,
visits: usize,
work: usize,
failed: bool,
}
impl OutlineBudget {
fn fail(&mut self, r: &mut Reader, warning: &'static str) -> Option<()> {
if !self.failed {
r.warn(warning);
}
self.failed = true;
None
}
fn spend(&mut self, r: &mut Reader, work: usize) -> Option<()> {
if work > self.work {
return self.fail(r, "compound outlines exceeding the geometry work limit");
}
self.work -= work;
Some(())
}
fn path(&mut self, r: &mut Reader, path: &Path) -> Option<()> {
for subpath in &path.subpaths {
self.spend(r, 1)?;
self.spend(r, subpath.segments.len())?;
}
Some(())
}
}
/// 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 mut budget = OutlineBudget { active: Vec::new(), visits: MAX_OUTLINE_VISITS, work: MAX_NODES, failed: false };
bounded_outline(r, id, class, world, &mut budget)
}
fn bounded_outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine, budget: &mut OutlineBudget) -> Option<(Path, bool)> {
if budget.active.contains(&id) {
budget.fail(r, "a compound outline that contains itself")?;
}
if budget.active.len() >= MAX_OUTLINE_DEPTH {
budget.fail(r, "compound outlines nested deeper than 128 levels")?;
}
if budget.visits == 0 {
budget.fail(r, "compound outlines exceeding the operand visit limit")?;
}
budget.visits -= 1;
budget.active.push(id);
let result = outline_inner(r, id, class, world, budget);
// The popped value is our own stack marker, and is no longer needed.
let _ = budget.active.pop();
result
}
fn outline_inner(r: &mut Reader, id: ObjId, class: Tag, world: Affine, budget: &mut OutlineBudget) -> 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 path = curves(r, crvs, budget)?;
budget.path(r, &path)?;
let multi = path.subpaths.len() > 1;
Some((path, multi))
}
@@ -29,8 +91,24 @@ pub(crate) fn outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine) -> O
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);
let child_world = local.then(world);
if !child_world.is_finite() {
budget.fail(r, "a compound operand with an invalid transform")?;
}
if let Some((mut p, _)) = bounded_outline(r, c, class, child_world, budget) {
// Charge before transforming or extending. Transform the owned path in place
// so nested compounds do not make an additional copy of every contour.
budget.path(r, &p)?;
for subpath in &mut p.subpaths {
subpath.start = local.apply(subpath.start);
for segment in &mut subpath.segments {
*segment = segment.map(|point| local.apply(point));
}
}
path.subpaths.extend(p.subpaths);
} else if budget.failed {
// Never present a partial compound as complete after a cycle or budget limit.
return None;
}
}
Some((path, true))
@@ -44,7 +122,9 @@ pub(crate) fn outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine) -> O
}
};
let b = s.floats::<4>(box_owner, b"ShpB")?;
crate::shapes::shape(r, shape, b, world).map(|p| (p, false))
let path = crate::shapes::shape(r, shape, b, world)?;
budget.path(r, &path)?;
Some((path, false))
}
}
}
@@ -53,7 +133,7 @@ pub(crate) fn outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine) -> O
/// 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> {
fn curves(r: &mut Reader, crvs: ObjId, budget: &mut OutlineBudget) -> Option<Path> {
let s = r.s;
let data = s.obj(crvs, b"Data")?;
let mut fields = s.positional(data);
@@ -62,6 +142,7 @@ pub(crate) fn curves(r: &mut Reader, crvs: ObjId) -> Option<Path> {
Value::UInt(n) => usize::try_from(*n).ok()?,
_ => return None,
};
budget.spend(r, count)?;
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;
@@ -73,9 +154,9 @@ pub(crate) fn curves(r: &mut Reader, crvs: ObjId) -> Option<Path> {
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;
budget.fail(r, "curves with more than four million nodes")?;
}
budget.spend(r, records.len())?;
let anchors = anchors(records)?;
let radii = if index == 0 { corners.as_ref() } else { None };
if let Some(sp) = subpath(&anchors, closed, radii) {
+30 -6
View File
@@ -172,6 +172,9 @@ pub struct TextRun {
/// CSS weight (400 regular, 700 bold).
pub weight: i64,
pub italic: bool,
/// Boolean OpenType feature overrides, as tags or `-tag` for disabled features.
pub features: Vec<String>,
pub all_caps: bool,
/// Font size in document pixels, before the node's transform.
pub size: f64,
/// Tracking in em.
@@ -223,10 +226,16 @@ pub enum Kind {
/// Layer container (`Scop`).
Layer,
Group,
/// Artboard: a rectangle that clips its children; `background` paints behind them.
/// Artboard: an export rectangle and vector outline that clips its children.
/// `background` paints behind them, and `strokes` over them.
Artboard {
rect: Rect,
/// Actual clipping outline in document pixels. The artboard's export rectangle is its
/// bounding box, but a rotated or non-rectangular board still clips to this outline.
path: Path,
even_odd: bool,
background: Vec<Paint>,
strokes: Vec<Stroke>,
},
/// 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).
@@ -436,7 +445,10 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
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) {
// Affinity 3.2.3's two-board public fixture marks board shapes with an `aprp`
// property object in `phrp`, while older documents use `ABEn`. Ordinary shapes
// in that fixture have neither. See the pinned corpus and validation audit.
if s.bool(id, b"ABEn") == Some(true) || s.obj(id, b"phrp").is_some_and(|p| s.is(p, b"aprp")) {
return Ok(self.artboard(id, world));
}
let Some((local, even_odd)) = crate::geometry::outline(self, id, class, world) else {
@@ -523,12 +535,15 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
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 Some(b) = s.floats::<4>(id, b"ShpB").map(rect).filter(|b| b.x1 > b.x0 && b.y1 > b.y0) else {
self.warn("an artboard without valid bounds (its children are kept without clipping)");
return Kind::Unsupported;
};
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)");
self.warn("a rotated or flipped artboard (export bounds are its bounding box; artwork keeps its outline)");
}
let xs = corners.map(|p| p.x);
let ys = corners.map(|p| p.y);
@@ -538,8 +553,17 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
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 }
if ![rect.x0, rect.y0, rect.x1, rect.y1].iter().all(|v| v.is_finite()) || rect.x1 <= rect.x0 || rect.y1 <= rect.y0 {
self.warn("an artboard with an invalid transformed size (its children are kept without clipping)");
return Kind::Unsupported;
}
let class = s.class(id).unwrap_or(Tag::of(b"ShpN"));
let (path, even_odd) = crate::geometry::outline(self, id, class, world).unwrap_or_else(|| {
self.warn("an artboard whose outline could not be read (clipped to its rectangle)");
(Path { subpaths: vec![crate::shapes::rectangle(b.x0, b.y0, b.x1, b.y1)] }, false)
});
let (background, strokes) = paint::node_paint(self, id, world);
Kind::Artboard { rect, path: path.transformed(world), even_odd, background, strokes }
}
}
+1 -1
View File
@@ -129,7 +129,7 @@ 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()),
Value::Bytes(b) if b.len() % 4 == 0 => Some(b.as_chunks::<4>().0.iter().map(|c| f64::from(f32::from_le_bytes(*c))).collect()),
_ => None,
}
}
+2 -5
View File
@@ -94,11 +94,8 @@ pub fn preview(bytes: &[u8]) -> Result<Preview<'_>, Error> {
}
image_data = true;
}
b"IEND" => {
if !image_data {
return Err(Error::Malformed("PNG has no image data"));
}
}
b"IEND" if !image_data => return Err(Error::Malformed("PNG has no image data")),
b"IEND" => {}
_ if kind.first().is_some_and(u8::is_ascii_uppercase) => {
return Err(Error::Unsupported("unknown critical PNG chunk"));
}
+175 -4
View File
@@ -13,6 +13,11 @@ const MAX_PIXELS: u64 = 64 << 20;
const TILE: usize = 256;
const TILE_BYTES: usize = TILE * TILE;
struct CachedPixels {
tiles: HashMap<String, Vec<u8>>,
source: Option<(usize, Vec<u8>)>,
}
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 {
@@ -53,7 +58,9 @@ pub(crate) fn node_image(r: &mut Reader, id: ObjId, world: Affine) -> Result<Opt
)
})
.unwrap_or((0, 0, width, height));
if s.field(bitmap, b"Bckg").is_some() && has_tiles && !s.is(id, b"ImgN") {
let source_tiles_only =
(1..=4).all(|c| matches!(s.field(bitmap, &tag(b"Sta", c)), Some(Value::Array(a)) if !a.is_empty() && a.iter().all(|v| *v == Value::UInt(5))));
if s.field(bitmap, b"Bckg").is_some() && has_tiles && !source_tiles_only && !s.is(id, b"ImgN") {
r.warn("edited placed images use their stored pixels");
}
decode(r, bitmap, crop, world)
@@ -118,7 +125,27 @@ fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affi
}
};
let mut planes = Vec::with_capacity(channels);
let mut cache = HashMap::new();
// Current .af documents can keep level-zero tiles in their embedded JPEG/PNG instead of
// duplicating the pixels. State 5 is checked in the public Patchy embedded-jpeg fixture.
// Support only the observed, same-size RGBA8 image with a zero bitmap origin; other
// representations still receive the existing loss warning rather than guessing a mapping.
let needs_source = (1..=channels).any(|c| matches!(s.field(bitmap, &tag(b"Sta", c)), Some(Value::Array(a)) if a.contains(&Value::UInt(5))));
let source = if needs_source && format == Some(0) && s.int(bitmap, b"LInf") == Some(0) && s.int(bitmap, b"TInf") == Some(0) {
if let Some(name) = s.entry(bitmap, b"Bckg") {
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);
match original(r, name).and_then(|bytes| source_pixels(&bytes, width, height)) {
Ok(pixels) => Some((width as usize, pixels)),
Err(Error::Limit(e)) => return Err(Error::Limit(e)),
Err(_) => None,
}
} else {
None
}
} else {
None
};
let mut cache = CachedPixels { tiles: HashMap::new(), source };
for c in 1..=channels {
planes.push(plane(r, bitmap, c, bps, (x0, y0, w, h), &mut cache)?);
}
@@ -131,7 +158,7 @@ fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affi
f64::from(p.get(i).copied().unwrap_or(0)) / 255.0
}
};
for (i, px) in rgba.chunks_exact_mut(4).enumerate() {
for (i, px) in rgba.as_chunks_mut::<4>().0.iter_mut().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)
@@ -158,6 +185,39 @@ fn original(r: &mut Reader, name: &str) -> Result<Vec<u8>, Error> {
}
}
/// Decode the source under the same pixel budget as tiled layers, checking its actual
/// dimensions before allocating decoded pixels. Only JPEG and PNG sources are supported.
fn source_pixels(bytes: &[u8], width: u32, height: u32) -> Result<Vec<u8>, Error> {
use image::{ImageFormat, ImageReader};
use std::io::Cursor;
if u64::from(width) * u64::from(height) > MAX_PIXELS {
return Err(Error::Limit("embedded image pixels"));
}
let mut reader = ImageReader::new(Cursor::new(bytes)).with_guessed_format().map_err(|_| Error::Malformed("embedded image format"))?;
if !matches!(reader.format(), Some(ImageFormat::Jpeg | ImageFormat::Png)) {
return Err(Error::Unsupported("embedded source pixel format"));
}
let dimensions = ImageReader::new(Cursor::new(bytes))
.with_guessed_format()
.map_err(|_| Error::Malformed("embedded image format"))?
.into_dimensions()
.map_err(|_| Error::Malformed("embedded image dimensions"))?;
if dimensions != (width, height) || width == 0 || height == 0 {
return Err(Error::Malformed("embedded source and bitmap dimensions differ"));
}
let mut limits = image::Limits::default();
limits.max_image_width = Some(width);
limits.max_image_height = Some(height);
limits.max_alloc = Some(MAX_PIXELS * 8);
reader.limits(limits);
let decoded = reader.decode().map_err(|e| match e {
image::ImageError::Limits(_) => Error::Limit("embedded image decoding"),
_ => Error::Malformed("embedded image decoding"),
})?;
Ok(decoded.into_rgba8().into_raw())
}
fn tag(prefix: &[u8; 3], c: usize) -> [u8; 4] {
[prefix[0], prefix[1], prefix[2], b'0' + c as u8]
}
@@ -169,8 +229,9 @@ fn plane(
c: usize,
bps: usize,
(x0, y0, w, h): (u32, u32, u32, u32),
cache: &mut HashMap<String, Vec<u8>>,
cache: &mut CachedPixels,
) -> Result<Vec<u8>, Error> {
let CachedPixels { tiles: cache, source } = cache;
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);
@@ -219,6 +280,13 @@ fn plane(
None
}
5 => {
if let Some((source_width, pixels)) = source.as_ref() {
let (ox, oy) = (tx * TILE, ty * TILE);
copy(&mut out, row, (bx0, py0, bx1, py1), (ox, oy), |x, y| {
pixels.get(((oy + y) * source_width + ox + x) * 4 + c - 1).copied().unwrap_or(0)
});
continue;
}
r.warn("pixel layers drawn from an embedded image (left empty)");
None
}
@@ -241,3 +309,106 @@ fn copy(out: &mut [u8], row: usize, (bx0, py0, bx1, py1): (usize, usize, usize,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::Kind;
use crate::synth::{self, F, Method, tag};
fn png() -> Vec<u8> {
let pixels = image::RgbaImage::from_fn(300, 2, |x, y| image::Rgba([(x % 251) as u8, (y * 31) as u8, 77, 255]));
let mut bytes = std::io::Cursor::new(Vec::new());
pixels.write_to(&mut bytes, image::ImageFormat::Png).unwrap();
bytes.into_inner()
}
/// An original synthetic mixed source/cache bitmap. It exercises reader recovery,
/// not a claim that Affinity itself accepts the synthetic container.
fn fixture(source: &[u8], origin: i32) -> Vec<u8> {
let mut bitmap = vec![
(tag(b"Frmt"), F::Enum(0, 0)),
(tag(b"BmpW"), F::I32(300)),
(tag(b"BmpH"), F::I32(2)),
(tag(b"LInf"), F::I32(origin)),
(tag(b"TInf"), F::I32(0)),
(tag(b"Bckg"), F::Entry("c/1".into())),
];
for c in 1..=4 {
bitmap.push((tag(&super::tag(b"TWi", c)), F::I32(2)));
bitmap.push((tag(&super::tag(b"THi", c)), F::I32(1)));
bitmap.push((tag(&super::tag(b"Sta", c)), F::U8s(vec![5, 4])));
bitmap.push((
tag(&super::tag(b"Idx", c)),
F::Shared(vec![F::Def(c as u32, vec![tag(b"Blck")], vec![(tag(b"Data"), F::Entry(format!("d/{c}")))])]),
));
}
let mut crop = vec![0x17];
crop.extend(tag(b"BitR").0.to_le_bytes());
crop.extend([250i32, 0, 270, 2].into_iter().flat_map(i32::to_le_bytes));
let raster = F::Def(
10,
vec![tag(b"Rstr")],
vec![
(tag(b"Bitm"), F::Obj(tag(b"DyBm"), bitmap)),
(tag(b"BitR"), F::Raw(crop)),
(tag(b"Xfrm"), F::F64s(vec![0.0, -2.0, 10.0, 2.0, 0.0, 20.0])),
],
);
let spread = F::Def(11, vec![tag(b"Sprd")], vec![(tag(b"Chld"), F::Shared(vec![raster]))]);
let doc = synth::stream(&[(tag(b"DocR"), F::Obj(tag(b"DocN"), vec![(tag(b"Chld"), F::Shared(vec![spread]))]))]);
let mut blob = vec![0x2d];
blob.extend(tag(b"Data").0.to_le_bytes());
blob.extend((source.len() as u32).to_le_bytes());
blob.extend(source);
let block = synth::stream(&[(tag(b"Data"), F::Raw(blob))]);
let tiles: Vec<_> = [200u8, 10, 30, 255].into_iter().map(|v| vec![v; TILE_BYTES]).collect();
synth::container(
&[
("doc.dat", &doc, Method::Zlib),
("c/1", &block, Method::Zlib),
("d/1", &tiles[0], Method::Zlib),
("d/2", &tiles[1], Method::Zlib),
("d/3", &tiles[2], Method::Zlib),
("d/4", &tiles[3], Method::Zlib),
],
None,
)
}
#[test]
fn source_tiles_and_cached_edits_survive_crop_rotation_and_tile_boundaries() {
let doc = crate::read(&fixture(&png(), 0), crate::Limits::default()).unwrap();
assert_eq!(doc.warnings, ["edited placed images use their stored pixels"]);
let Kind::Image(image) = &doc.spreads[0].nodes[0].kind else { panic!() };
assert_eq!((image.width, image.height), (20, 2));
assert_eq!(image.transform, Affine([0.0, 2.0, -2.0, 0.0, 10.0, 520.0]));
let Pixels::Rgba8(pixels) = &image.pixels else { panic!() };
for y in 0..2 {
for x in 0..20 {
let expected = if x + 250 < 256 { [((x + 250) % 251) as u8, (y * 31) as u8, 77, 255] } else { [200, 10, 30, 255] };
assert_eq!(&pixels[(y * 20 + x) * 4..(y * 20 + x + 1) * 4], &expected);
}
}
}
#[test]
fn unreadable_sources_and_unknown_origins_preserve_cached_edits_and_warn() {
for bytes in [fixture(b"invalid image", 0), fixture(&png(), 1)] {
let doc = crate::read(&bytes, crate::Limits::default()).unwrap();
assert!(doc.warnings.iter().any(|w| w == "pixel layers drawn from an embedded image (left empty) (4×)"));
let Kind::Image(image) = &doc.spreads[0].nodes[0].kind else { panic!() };
let Pixels::Rgba8(pixels) = &image.pixels else { panic!() };
assert_eq!(&pixels[..4], &[0, 0, 0, 0]);
assert_eq!(&pixels[6 * 4..7 * 4], &[200, 10, 30, 255]);
}
}
#[test]
fn embedded_source_dimensions_and_pixel_budget_are_checked_before_decode() {
let bytes = png();
assert!(matches!(source_pixels(&bytes, 299, 2), Err(Error::Malformed(_))));
assert!(matches!(source_pixels(&bytes, u32::MAX, u32::MAX), Err(Error::Limit(_))));
assert_eq!(source_pixels(&bytes, 300, 2).unwrap().len(), 300 * 2 * 4);
}
}
+14 -6
View File
@@ -115,13 +115,21 @@ pub(crate) fn shape(r: &mut Reader, shape: ObjId, b: [f64; 4], world: Affine) ->
}))?
}
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 raw_start = num(r, shape, b"AngS").unwrap_or(0.0);
let raw_end = num(r, shape, b"AngE").unwrap_or(TAU);
// Normalize in constant time. Repeatedly adding TAU can hang on finite values
// large enough that floating-point addition no longer changes the endpoint.
let start = raw_start.rem_euclid(TAU);
let delta = raw_end - raw_start;
let sweep = if raw_end >= raw_start {
delta.min(TAU)
} else if delta.is_finite() {
delta.rem_euclid(TAU)
} else {
(raw_end.rem_euclid(TAU) - start).rem_euclid(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)
pie(cx, cy, rx, ry, start, start + sweep, inner)
}
b"ShpT" => {
let p = num(r, shape, b"Pos ").unwrap_or(0.5);
+201 -30
View File
@@ -3,11 +3,13 @@
//! 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::paint::{self, Color, 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;
/// Shared character attributes must not amplify into unbounded font/feature/gradient data.
const MAX_ATTRIBUTE_BYTES: usize = 16 << 20;
pub(crate) fn read(r: &mut Reader, id: ObjId, world: Affine) -> Option<Text> {
let s = r.s;
@@ -19,46 +21,76 @@ pub(crate) fn read(r: &mut Reader, id: ObjId, world: Affine) -> Option<Text> {
let mut align = None;
let mut total = 0usize;
let mut placeholders = false;
let mut attribute_budget = Some(MAX_ATTRIBUTE_BYTES);
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(),
let Some(glyphs) = s.obj(block, b"Glyp") else {
r.warn("a text block without readable characters (left out)");
continue;
};
// Bound collection itself, including placeholders, before allocating the character buffer.
let budget = MAX_CHARS - total;
let mut chars: Vec<char> = match s.str(glyphs, b"Utf8") {
Some(t) => t.chars().take(budget + 1).collect(),
None => {
// Inline objects (page numbers, anchors) each take one index before the segment text.
let segments = s.objs(glyphs, b"Mixd");
if segments.is_empty() {
r.warn("a text block without readable characters (left out)");
continue;
}
let mut v = Vec::new();
for seg in s.objs(glyphs, b"Mixd") {
for seg in segments {
let inline = s.objs(seg, b"Glys").len();
v.extend(std::iter::repeat_n('\u{FFFC}', inline));
v.extend(std::iter::repeat_n('\u{FFFC}', inline.min(budget + 1 - v.len())));
placeholders |= inline > 0;
v.extend(s.str(seg, b"Utf8").unwrap_or_default().chars());
v.extend(s.str(seg, b"Utf8").unwrap_or_default().chars().take(budget + 1 - v.len()));
if v.len() > budget {
break;
}
}
v
}
};
total = total.checked_add(chars.len())?;
if total > MAX_CHARS {
let truncated = chars.len() > budget;
if truncated {
chars.truncate(budget);
r.warn("text longer than a million characters (truncated)");
break;
}
total += chars.len();
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());
let Some(index) = s.int(run, b"Indx").and_then(|v| usize::try_from(v).ok()) else {
r.warn("invalid text attribute range (bounded; unassigned characters use defaults)");
continue;
};
let end = index.min(chars.len());
if index > chars.len() || end < start {
r.warn("invalid text attribute range (bounded; unassigned characters use defaults)");
}
if end <= start {
continue;
}
let text: String = chars.get(start..end)?.iter().filter(|c| **c != '\0' && **c != '\u{FFFC}').collect();
let text = characters(&chars, start, end);
start = end;
let Some(attrs) = s.obj(run, b"Item") else { continue };
if !text.is_empty() {
runs.push(run_attrs(r, attrs, text, world));
push_run(r, &mut runs, s.obj(run, b"Item"), text, world, &mut attribute_budget);
}
if start < chars.len() {
let text = characters(&chars, start, chars.len());
push_run(r, &mut runs, None, text, world, &mut attribute_budget);
}
for paragraph in s.obj(block, b"PAtt").map(|p| s.objs(p, b"Runs")).unwrap_or_default() {
if let Some(attrs) = s.obj(paragraph, b"Item") {
let paragraph = paragraph_align(r, attrs);
match align {
None => align = Some(paragraph),
Some(first) if first != paragraph => r.warn("mixed paragraph alignments (the first alignment is used)"),
_ => {}
}
}
}
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 truncated {
break;
}
}
if placeholders {
@@ -82,6 +114,39 @@ pub(crate) fn read(r: &mut Reader, id: ObjId, world: Affine) -> Option<Text> {
Some(Text { runs, align, anchor: Point { x: anchor_x, y: first_baseline }, frame: if artistic { None } else { Some(bounds) }, transform: world })
}
fn characters(chars: &[char], start: usize, end: usize) -> String {
chars.get(start..end).unwrap_or_default().iter().filter(|c| **c != '\0' && **c != '\u{FFFC}').collect()
}
fn push_run(r: &mut Reader, runs: &mut Vec<TextRun>, attrs: Option<ObjId>, text: String, world: Affine, attribute_budget: &mut Option<usize>) {
if text.is_empty() {
return;
}
let run = match attrs {
Some(attrs) if attribute_budget.is_some() => run_attrs(r, attrs, text, world, attribute_budget),
_ => {
// This is a recovery policy, not an assertion about unspecified Affinity attributes.
if attrs.is_none() {
r.warn("text without character attributes (characters preserved with default attributes)");
}
TextRun {
text,
postscript: String::new(),
family: String::new(),
weight: 400,
italic: false,
features: Vec::new(),
all_caps: false,
size: 12.0 * r.dpi / 72.0,
tracking: 0.0,
leading: None,
fill: Paint::Solid(Color::Gray { v: 0.0, a: 1.0 }),
}
}
};
runs.push(run);
}
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),
@@ -104,7 +169,7 @@ fn int(v: &Value) -> Option<i64> {
}
}
fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine) -> TextRun {
fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine, attribute_budget: &mut Option<usize>) -> TextRun {
let s = r.s;
let doubles = s.field(attrs, b"Doub");
let ints = s.field(attrs, b"Ints");
@@ -112,24 +177,67 @@ fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine) -> TextR
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(),
Some(Value::Array(a)) => a.iter().take(8).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) {
let mut fill = match objects.first().copied().flatten().and_then(|f| paint::descriptor(r, f, world)) {
Some(fill) => fill,
None => {
r.warn("text without readable fill attributes (default black used)");
Paint::Solid(Color::Gray { v: 0.0, a: 1.0 })
}
};
// A single descriptor decode is bounded by paint::MAX_STOPS. Charge its owned stop data
// before keeping it; subsequent runs skip attribute decoding once the budget is exhausted.
if let Paint::Gradient(gradient) = &fill {
let bytes = gradient.stops.len().saturating_mul(std::mem::size_of::<paint::Stop>());
if !charge_attributes(r, attribute_budget, bytes) {
fill = Paint::Solid(Color::Gray { v: 0.0, a: 1.0 });
}
}
if attribute_budget.is_some() && 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();
// A resolved font is populated on the emoji run of the public Affinity 3 text-runs file
// (Courier New requested, Segoe UI Emoji stored in RFnt). Empty RFnt records are common.
// Source: https://github.com/SethRobinson/Patchy/tree/de84eab550758b30fa062e479f5778cce7693b73/test-fixtures/af
let requested = s.obj(attrs, b"DFnt");
let resolved =
s.obj(attrs, b"RFnt").filter(|f| !s.str(*f, b"Famy").unwrap_or_default().is_empty() || !s.str(*f, b"Post").unwrap_or_default().is_empty());
let font = resolved.or(requested);
if let Some(resolved) = resolved {
let differs = [b"Famy", b"Post"]
.iter()
.any(|tag| s.str(resolved, tag).unwrap_or_default() != requested.and_then(|f| s.str(f, tag)).unwrap_or_default());
if differs {
r.warn("text uses a stored fallback font (imported with that font)");
}
}
if font.and_then(|f| s.int(f, b"Widh")).is_some_and(|w| w != 5) {
r.warn("font width variants (exact face depends on the installed PostScript font)");
}
let postscript = metadata_string(r, attribute_budget, font.and_then(|f| s.str(f, b"Post")).unwrap_or_default());
let family = metadata_string(r, attribute_budget, font.and_then(|f| s.str(f, b"Famy")).unwrap_or_default());
let (features, all_caps) = font_features(r, objects.get(7).copied().flatten(), attribute_budget);
let weight = font.and_then(|f| s.int(f, b"Wegt")).unwrap_or(400);
let weight = if (1..=1000).contains(&weight) {
weight
} else {
r.warn("invalid font weight (imported as regular)");
400
};
TextRun {
text,
postscript: get(b"Post"),
family: get(b"Famy"),
weight: font.and_then(|f| s.int(f, b"Wegt")).unwrap_or(400),
postscript,
family,
weight,
italic: font.and_then(|f| s.bool(f, b"Ital")).unwrap_or(false),
features,
all_caps,
size,
tracking,
leading,
@@ -137,7 +245,70 @@ fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine) -> TextR
}
}
fn charge_attributes(r: &mut Reader, budget: &mut Option<usize>, bytes: usize) -> bool {
let Some(remaining) = *budget else { return false };
match remaining.checked_sub(bytes) {
Some(remaining) => {
*budget = Some(remaining);
true
}
None => {
*budget = None;
r.warn("text attribute budget exceeded (excess font names, features and gradients left out)");
false
}
}
}
fn metadata_string(r: &mut Reader, budget: &mut Option<usize>, value: &str) -> String {
let bytes = value.len().saturating_add(std::mem::size_of::<String>());
if value.is_empty() || !charge_attributes(r, budget, bytes) { String::new() } else { value.to_owned() }
}
/// The public Affinity 3 caps fixture stores `smcp` and the private `CAP\x01` selector in
/// OtAt/Setn. Only the observed all-caps selector and boolean OpenType tags are mapped; other
/// values are reported, never guessed. Standard tag syntax: https://learn.microsoft.com/en-us/typography/opentype/spec/featuretags
fn font_features(r: &mut Reader, attrs: Option<ObjId>, attribute_budget: &mut Option<usize>) -> (Vec<String>, bool) {
let mut features = Vec::new();
let mut all_caps = false;
if attribute_budget.is_none() {
return (features, all_caps);
}
let s = r.s;
let Some(Value::Array(settings)) = attrs.and_then(|a| s.field(a, b"Setn")) else { return (features, all_caps) };
for (index, setting) in settings.iter().take(257).enumerate() {
if index == 256 {
r.warn("more than 256 text feature settings (truncated)");
break;
}
let Value::Obj(Some(setting)) = setting else {
r.warn("unreadable text feature settings (imported with defaults)");
continue;
};
let setting = *setting;
let tag = s.int(setting, b"Feat").and_then(|v| u32::try_from(v).ok()).map(u32::to_be_bytes);
let value = r.s.int(setting, b"Valu");
match (tag, value) {
(Some([b'C', b'A', b'P', 1]), Some(0 | 1)) => all_caps = value == Some(1),
(Some(tag), Some(value @ (0 | 1))) if tag.iter().all(u8::is_ascii_alphanumeric) => {
if !charge_attributes(r, attribute_budget, std::mem::size_of::<String>() + if value == 0 { 5 } else { 4 }) {
break;
}
let name = String::from_utf8_lossy(&tag);
features.push(if value == 0 { format!("-{name}") } else { name.into_owned() });
}
_ => r.warn("text feature selectors or non-boolean OpenType settings (imported with defaults)"),
}
}
(features, all_caps)
}
fn paragraph_align(r: &mut Reader, p: ObjId) -> Align {
// Nonzero offsets in these slots are present in the public text-indent and text-para-spacing
// fixtures. Their layout is not represented by this importer; retain text and report the gap.
if (1..=6).any(|i| slot(r.s.field(p, b"Doub"), i, float).is_some_and(|v| v.abs() > 1e-9)) {
r.warn("paragraph indents or spacing (imported with defaults)");
}
match slot(r.s.field(p, b"Ints"), 0, int) {
Some(1) => Align::Center,
Some(2) => Align::Right,
+168
View File
@@ -0,0 +1,168 @@
//! Original synthetic graph tests for bounded `.af` outline expansion. These model the reader's
//! accepted schema, without using Affinity software, third-party parser code or external assets.
#![cfg(feature = "synth")]
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use vectorcraft_affinity::{
Document, Limits,
model::{Kind, Path, Point},
synth::{self, F, Method, tag},
};
fn compound(id: u32, children: Vec<F>) -> F {
F::Def(id, vec![tag(b"Comp")], vec![(tag(b"Chld"), F::Shared(children))])
}
fn shape(id: u32, class: &[u8; 4], parameters: Vec<(vectorcraft_affinity::stream::Tag, F)>) -> F {
F::Def(
id,
vec![tag(b"ShpN")],
vec![(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 10.0, 20.0])), (tag(b"Shpe"), F::Def(id + 10_000, vec![tag(class)], parameters))],
)
}
fn read(definitions: Vec<F>, top: u32) -> Document {
let data = synth::stream(&[
(tag(b"Defs"), F::Shared(definitions)),
(
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"SprB"), F::F64s(vec![0.0, 0.0, 400.0, 300.0])), (tag(b"Chld"), F::Shared(vec![F::Ref(top)]))],
)]),
)],
),
),
]);
let bytes = synth::container(&[("doc.dat", &data, Method::Zlib)], None);
vectorcraft_affinity::read(&bytes, Limits::default()).unwrap()
}
fn path(document: &Document) -> &Path {
match &document.spreads[0].nodes[0].kind {
Kind::Shape { path, .. } => path,
other => panic!("expected shape, got {other:?}"),
}
}
fn rejected(document: &Document, warning: &str) {
assert!(matches!(document.spreads[0].nodes[0].kind, Kind::Unsupported));
assert!(document.warnings.iter().any(|value| value.contains(warning)), "{:?}", document.warnings);
}
#[test]
fn self_cycle_and_mutual_cycle_discard_the_entire_compound() {
// A valid first operand ensures a malformed later operand cannot leave a plausible partial path.
let rectangle = shape(100, b"ShNR", vec![]);
let self_cycle = compound(101, vec![F::Ref(100), F::Ref(101)]);
rejected(&read(vec![rectangle.clone(), self_cycle], 101), "compound outline that contains itself");
let mutual_cycle = compound(101, vec![F::Ref(100), compound(102, vec![F::Ref(101)])]);
rejected(&read(vec![rectangle, mutual_cycle], 101), "compound outline that contains itself");
}
#[test]
fn deep_reference_chain_is_bounded_independently_of_stream_nesting() {
let mut definitions = vec![shape(100, b"ShNR", vec![])];
for id in 101..=250 {
definitions.push(compound(id, vec![F::Ref(id - 1)]));
}
rejected(&read(definitions, 250), "compound outlines nested deeper than 128 levels");
}
#[test]
fn outline_at_the_depth_limit_is_still_imported() {
let mut definitions = vec![shape(100, b"ShNR", vec![])];
for id in 101..=227 {
definitions.push(compound(id, vec![F::Ref(id - 1)]));
}
let document = read(definitions, 227);
assert!(document.warnings.is_empty(), "{:?}", document.warnings);
assert_eq!(path(&document).subpaths.len(), 1);
assert_eq!(path(&document).subpaths[0].segments.len(), 3);
}
#[test]
fn excessive_declared_curve_count_is_rejected_before_expansion() {
let curve = F::Def(
100,
vec![tag(b"PCrv")],
vec![(tag(b"Crvs"), F::Def(101, vec![tag(b"PCvD")], vec![(tag(b"Data"), F::Pos(vec![F::U8(0), F::U32(u32::MAX)]))]))],
);
rejected(&read(vec![curve], 100), "compound outlines exceeding the geometry work limit");
}
#[test]
fn empty_shared_fanout_cannot_bypass_the_operand_visit_limit() {
// Only 21 on-disk objects, but an unbounded traversal would visit over two million operands.
let mut definitions = vec![compound(100, vec![])];
for id in 101..=120 {
definitions.push(compound(id, vec![F::Ref(id - 1), F::Ref(id - 1)]));
}
rejected(&read(definitions, 120), "compound outlines exceeding the operand visit limit");
}
#[test]
fn shared_large_operands_are_charged_for_cumulative_geometry_work() {
// A small reference graph expands a 20,000-segment star. Count work across every reuse and
// transform, rather than permitting each individual operand's allocation limit repeatedly.
let mut definitions = vec![shape(100, b"ShSt", vec![(tag(b"Pnts"), F::I32(10_000))])];
for id in 101..=112 {
definitions.push(compound(id, vec![F::Ref(id - 1), F::Ref(id - 1)]));
}
rejected(&read(definitions, 112), "compound outlines exceeding the geometry work limit");
}
#[test]
fn ordinary_nested_compound_preserves_repeated_operands_and_transforms() {
let mut rectangle = shape(100, b"ShNR", vec![]);
if let F::Def(_, _, fields) = &mut rectangle {
fields.push((tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, 3.0, 0.0, 1.0, 7.0])));
}
let inner = compound(101, vec![F::Ref(100), F::Ref(100)]);
let mut outer = compound(102, vec![F::Ref(101)]);
if let F::Def(_, _, fields) = &mut outer {
fields.push((tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, 100.0, 0.0, 1.0, 200.0])));
}
let document = read(vec![rectangle, inner, outer], 102);
assert!(document.warnings.is_empty(), "{:?}", document.warnings);
let path = path(&document);
assert_eq!(path.subpaths.len(), 2);
assert_eq!(path.subpaths[0], path.subpaths[1]);
assert_eq!(path.subpaths[0].start, Point { x: 103.0, y: 207.0 });
assert_eq!(path.subpaths[0].segments.last().unwrap()[2], Point { x: 103.0, y: 227.0 });
}
#[test]
fn overflowing_compound_transform_is_reported_before_processing_geometry() {
let mut child = shape(100, b"ShNR", vec![]);
if let F::Def(_, _, fields) = &mut child {
fields.push((tag(b"Xfrm"), F::F64s(vec![1.0e308, 0.0, 0.0, 0.0, 1.0, 0.0])));
}
let mut outer = compound(101, vec![F::Ref(100)]);
if let F::Def(_, _, fields) = &mut outer {
fields.push((tag(b"Xfrm"), F::F64s(vec![1.0e308, 0.0, 0.0, 0.0, 1.0, 0.0])));
}
rejected(&read(vec![child, outer], 101), "compound operand with an invalid transform");
}
#[test]
fn pie_angles_with_huge_finite_values_do_not_loop_or_emit_nonfinite_points() {
let document = read(vec![shape(100, b"ShPi", vec![(tag(b"AngS"), F::F64(1.0e308)), (tag(b"AngE"), F::F64(-1.0e308))])], 100);
for subpath in &path(&document).subpaths {
assert!(subpath.start.x.is_finite() && subpath.start.y.is_finite());
assert!(subpath.segments.len() <= 20);
assert!(subpath.segments.iter().flatten().all(|point| point.x.is_finite() && point.y.is_finite()));
}
}
#[test]
fn wrapped_pie_angles_keep_the_same_geometry_as_the_positive_span() {
let make = |end| read(vec![shape(100, b"ShPi", vec![(tag(b"AngS"), F::F64(0.0)), (tag(b"AngE"), F::F64(end))])], 100);
assert_eq!(path(&make(-std::f64::consts::FRAC_PI_2)), path(&make(3.0 * std::f64::consts::FRAC_PI_2)));
}
+116
View File
@@ -0,0 +1,116 @@
//! Pixel retention on current Affinity 3 documents published under MIT by Patchy.
//! Assertions use this reader's archive/stream and the original embedded image bytes;
//! no third-party Affinity parser, source code or application is used as an oracle.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use vectorcraft_affinity::{
Archive, Limits,
model::{Affine, Document, Image, Kind, Node, Pixels},
stream,
};
#[path = "support/corpus.rs"]
mod corpus;
fn images(nodes: &[Node], out: &mut Vec<Image>) {
for node in nodes {
if let Kind::Image(image) = &node.kind {
out.push(image.clone());
}
images(&node.children, out);
}
}
fn read_fixture(name: &str) -> Option<(Vec<u8>, Document, Vec<Image>)> {
let dir = corpus::pinned()?;
let bytes = std::fs::read(dir.join(name)).unwrap();
let doc = vectorcraft_affinity::read(&bytes, Limits::default()).unwrap();
assert!(doc.saved_by.as_deref().unwrap().starts_with("Affinity 3."));
let mut out = Vec::new();
for spread in &doc.spreads {
images(&spread.nodes, &mut out);
}
Some((bytes, doc, out))
}
fn source(bytes: &[u8]) -> Vec<u8> {
let mut archive = Archive::open(bytes, Limits::default()).unwrap();
let doc = stream::parse(&archive.read("doc.dat").unwrap()).unwrap();
let bitmap =
doc.objects.iter().enumerate().find(|(_, o)| o.class == stream::Tag::of(b"DyBm") && o.get(stream::Tag::of(b"Bckg")).is_some()).unwrap().0;
let name = doc.entry(bitmap, b"Bckg").unwrap();
let block = stream::parse(&archive.read(name).unwrap()).unwrap();
block.bytes(block.root, b"Data").unwrap().to_vec()
}
#[test]
fn source_backed_tiles_retain_the_embedded_jpeg_pixels() {
let Some((bytes, doc, images)) = read_fixture("patchy-embedded-jpeg.af") else { return };
assert!(doc.warnings.is_empty(), "{:?}", doc.warnings);
assert_eq!(images.len(), 1);
let image = &images[0];
assert_eq!((image.width, image.height), (400, 300));
assert_eq!(image.transform, Affine::IDENTITY);
let Pixels::Rgba8(pixels) = &image.pixels else { panic!("expected retained level-zero pixels") };
let expected = image::load_from_memory(&source(&bytes)).unwrap().into_rgba8();
assert_eq!(pixels.as_slice(), expected.as_raw());
assert!(pixels.as_chunks::<4>().0.iter().all(|p| p[3] == 255));
assert!(pixels.as_chunks::<4>().0.iter().any(|p| p[0] != p[1]));
}
#[test]
fn lazy_placed_images_keep_the_original_encoded_bytes() {
let Some((bytes, doc, images)) = read_fixture("patchy-lazy-placed.af") else { return };
assert!(doc.warnings.is_empty());
assert_eq!(images.len(), 1);
assert_eq!((images[0].width, images[0].height), (400, 300));
assert_eq!(images[0].transform, Affine::IDENTITY);
assert_eq!(images[0].pixels, Pixels::Encoded(source(&bytes)));
}
#[test]
fn eight_and_sixteen_bit_channels_retain_the_same_pixel_pattern() {
let Some((_, doc8, images8)) = read_fixture("patchy-rgba8.af") else { return };
let (_, doc16, images16) = read_fixture("patchy-rgba16.af").unwrap();
assert!(doc8.warnings.is_empty());
assert!(doc16.warnings.is_empty());
assert_eq!(images8.len(), 1);
assert_eq!(images16, images8);
assert_eq!((images8[0].width, images8[0].height), (64, 48));
let Pixels::Rgba8(pixels) = &images8[0].pixels else { panic!() };
assert_eq!(pixels.len(), 64 * 48 * 4);
assert_eq!(&pixels[..4], &[0, 0, 0, 128]);
assert!(pixels.as_chunks::<4>().0.iter().any(|p| p[3] < 255));
assert!(pixels.as_chunks::<4>().0.iter().any(|p| p[0] != p[1]));
// Frozen pixel-retention baseline, including every alpha sample. It is not a render oracle.
assert_eq!(crc32fast::hash(pixels), 0xc8ee4dc1);
}
#[test]
fn rotated_scaled_pixels_keep_the_full_affine_transform() {
let Some((_, doc, images)) = read_fixture("patchy-transform.af") else { return };
assert!(doc.warnings.is_empty());
assert_eq!(images.len(), 1);
assert_eq!((images[0].width, images[0].height), (80, 60));
let expected = [1.1742158910592235, 0.4286222593193142, -0.4286222593193142, 1.1742158910592235, 60.0, 40.0];
for (actual, expected) in images[0].transform.0.into_iter().zip(expected) {
assert!((actual - expected).abs() < 1e-12);
}
let Pixels::Rgba8(pixels) = &images[0].pixels else { panic!() };
assert_eq!(pixels.len(), 80 * 60 * 4);
assert!(pixels.as_chunks::<4>().0.iter().all(|p| p[3] == 255));
assert_eq!(crc32fast::hash(pixels), 0x3720a442);
}
#[test]
fn cmyk_and_lab_losses_are_reported() {
let Some((_, cmyk, images)) = read_fixture("patchy-cmyk.af") else { return };
assert_eq!(images.len(), 1);
assert_eq!((images[0].width, images[0].height), (64, 48));
let Pixels::Rgba8(pixels) = &images[0].pixels else { panic!() };
assert_eq!(crc32fast::hash(pixels), 0x5872bb76);
assert_eq!(cmyk.warnings, ["CMYK pixel layers (converted to RGB without the document's colour profile)"]);
let (_, lab, images) = read_fixture("patchy-lab.af").unwrap();
assert!(images.is_empty());
assert_eq!(lab.warnings, ["pixel layers in grey, Lab or 32-bit formats"]);
}
+41 -9
View File
@@ -1,28 +1,32 @@
//! 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.
//! `AFFINITY_SAMPLES` (searched recursively). CI requires a complete, checksum-verified pinned
//! corpus via `AFFINITY_CORPUS_REQUIRED=1`; ordinary local runs may skip an absent corpus.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::path::{Path, PathBuf};
use vectorcraft_affinity::{Archive, Limits, stream};
#[path = "support/corpus.rs"]
mod corpus;
#[path = "support/features.rs"]
mod features;
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))
{
} else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| ["af", "afdesign", "afphoto", "afpub", "aftemplate"].contains(&e)) {
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))
let pinned = corpus::pinned();
std::env::var_os("AFFINITY_SAMPLES").map(PathBuf::from).or(pinned)
}
#[test]
@@ -52,6 +56,7 @@ fn public_documents_map_to_the_model() {
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 warnings = std::collections::BTreeMap::<String, usize>::new();
let mut failures = Vec::new();
let (mut nodes, mut docs) = (0usize, 0usize);
@@ -60,9 +65,6 @@ fn public_documents_map_to_the_model() {
}
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;
@@ -81,3 +83,33 @@ fn public_documents_map_to_the_model() {
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
/// Measured structural baselines for actual Affinity 3 files. Unlike a parse-success test,
/// this detects disappearing nodes, curves, runs, font names, artboards and loss warnings.
/// It does not claim that an unsupported feature is faithfully rendered.
#[test]
fn affinity3_feature_retention_matches_pinned_signatures() {
let Some(dir) = corpus::pinned() else { return };
let expected = include_str!("support/affinity3-signatures.txt");
let mut checked = std::collections::BTreeSet::new();
for line in expected.lines().filter(|l| !l.starts_with('#') && !l.is_empty()) {
let (name, signature) = line.split_once('\t').expect("signature row must have a tab");
assert!(checked.insert(name), "duplicate fixture {name}");
let bytes = std::fs::read(dir.join(name)).unwrap();
let doc = vectorcraft_affinity::read(&bytes, Limits::default()).unwrap();
assert_eq!(
features::signature(&doc),
signature,
"{name}: imported feature retention changed; review the pinned input and warning changes before updating this baseline"
);
assert!(
doc.saved_by.as_deref().is_some_and(|v| v.starts_with("Affinity 3.")),
"{name}: expected an actual Affinity 3 document, got {:?}",
doc.saved_by
);
}
let manifest = include_str!("../../../xtask/affinity-corpus.sha256");
let af_names: std::collections::BTreeSet<_> =
manifest.lines().filter_map(|l| l.split_once(" ")).map(|(_, n)| n).filter(|n| n.ends_with(".af")).collect();
assert_eq!(checked, af_names, "every pinned .af file needs a reviewed structural baseline");
}
@@ -0,0 +1,33 @@
affinity3-mexican-guy.af dpi=300;spreads=1;model_crc32=df3c417b;counts={"depth": 8, "fills": 169, "gradients": 0, "groups": 23, "nodes": 283, "opacity": 1, "segments": 1491, "shapes": 260, "strokes": 114, "subpaths": 264};boards=[];fonts={};warnings=["brush and pressure strokes (imported as plain strokes) (114×)", "layer effects (shadows, glows, outlines, bevels…) (156×)"]
affinity3-mexican-man.af dpi=300;spreads=1;model_crc32=d0387de4;counts={"depth": 7, "fills": 277, "gradients": 2, "groups": 29, "nodes": 903, "segments": 3225, "shapes": 874, "strokes": 559, "subpaths": 881};boards=[];fonts={};warnings=["brush and pressure strokes (imported as plain strokes) (545×)", "layer effects (shadows, glows, outlines, bevels…) (245×)"]
affinity3-mexican-woman.af dpi=300;spreads=1;model_crc32=b966f693;counts={"depth": 9, "fills": 527, "gradients": 0, "groups": 28, "nodes": 595, "segments": 3562, "shapes": 567, "strokes": 60, "subpaths": 570};boards=[];fonts={};warnings=["brush and pressure strokes (imported as plain strokes) (54×)", "layer effects (shadows, glows, outlines, bevels…) (278×)"]
affinity3-playing-cards.af dpi=300;spreads=1;model_crc32=ac448123;counts={"depth": 5, "fills": 363, "gradients": 0, "groups": 758, "nodes": 1350, "segments": 4864, "shapes": 592, "strokes": 288, "subpaths": 656};boards=[];fonts={};warnings=["brush and pressure strokes (imported as plain strokes)", "layer effects (shadows, glows, outlines, bevels…) (339×)"]
patchy-adjust-curves.af dpi=72;spreads=1;model_crc32=10a37502;counts={"depth": 1, "images": 1, "nodes": 2, "unsupported": 1};boards=[];fonts={};warnings=["adjustment layers"]
patchy-adjust-hsl.af dpi=72;spreads=1;model_crc32=c65409b4;counts={"depth": 1, "images": 1, "nodes": 2, "unsupported": 1};boards=[];fonts={};warnings=["adjustment layers"]
patchy-artboards.af dpi=72;spreads=1;model_crc32=f1ae5644;counts={"artboards": 2, "depth": 2, "fills": 2, "gradients": 0, "nodes": 4, "segments": 7, "shapes": 2, "strokes": 0, "subpaths": 2};boards=["board-a:0.0000,0.0000,80.0000,60.0000", "board-b:100.0000,10.0000,160.0000,70.0000"];fonts={};warnings=[]
patchy-blend-affinity.af dpi=72;spreads=1;model_crc32=f6691ff9;counts={"depth": 2, "fills": 8, "gradients": 0, "images": 1, "layers": 1, "nodes": 10, "segments": 24, "shapes": 8, "strokes": 0, "subpaths": 8};boards=[];fonts={};warnings=["an unknown blend mode (imported as Normal) (2×)"]
patchy-cmyk.af dpi=72;spreads=1;model_crc32=5145b5a1;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=["CMYK pixel layers (converted to RGB without the document's colour profile)"]
patchy-dpi300.af dpi=300;spreads=1;model_crc32=0c23e7c8;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
patchy-embedded-jpeg.af dpi=72;spreads=1;model_crc32=090c674b;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
patchy-fx-blur.af dpi=72;spreads=1;model_crc32=3f1b8b44;counts={"depth": 1, "images": 2, "nodes": 2};boards=[];fonts={};warnings=["layer effects (shadows, glows, outlines, bevels…) (2×)"]
patchy-fx-gradient.af dpi=72;spreads=1;model_crc32=e868c992;counts={"depth": 1, "images": 3, "nodes": 3};boards=[];fonts={};warnings=["layer effects (shadows, glows, outlines, bevels…) (3×)"]
patchy-fx.af dpi=72;spreads=1;model_crc32=5a1c34ac;counts={"depth": 1, "images": 5, "nodes": 5};boards=[];fonts={};warnings=["layer effects (shadows, glows, outlines, bevels…) (5×)"]
patchy-group.af dpi=72;spreads=1;model_crc32=c217ab38;counts={"depth": 2, "images": 3, "layers": 1, "nodes": 4};boards=[];fonts={};warnings=[]
patchy-incremental-chain.af dpi=72;spreads=1;model_crc32=63ad778e;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=["layer effects (shadows, glows, outlines, bevels…)"]
patchy-lab.af dpi=72;spreads=1;model_crc32=01e8a926;counts={"depth": 1, "nodes": 1, "unsupported": 1};boards=[];fonts={};warnings=["pixel layers in grey, Lab or 32-bit formats"]
patchy-lazy-placed.af dpi=72;spreads=1;model_crc32=9ce9937e;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
patchy-live-filter.af dpi=72;spreads=1;model_crc32=30c5f8ee;counts={"depth": 2, "fills": 2, "gradients": 0, "nodes": 4, "segments": 6, "shapes": 2, "strokes": 0, "subpaths": 2, "unsupported": 2};boards=[];fonts={};warnings=["adjustment layers", "unknown layers"]
patchy-rgba16.af dpi=72;spreads=1;model_crc32=1bb23571;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
patchy-rgba8.af dpi=72;spreads=1;model_crc32=1bb23571;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
patchy-shapes-2.af dpi=72;spreads=1;model_crc32=7828d3cd;counts={"depth": 1, "fills": 12, "gradients": 0, "nodes": 12, "segments": 62, "shapes": 12, "strokes": 0, "subpaths": 12};boards=[];fonts={};warnings=["cloud, heart, cog, callout, arrow, tear and other special shapes (imported as their bounding ellipse) (9×)"]
patchy-shapes.af dpi=72;spreads=1;model_crc32=e127e129;counts={"depth": 1, "fills": 4, "gradients": 0, "nodes": 4, "segments": 29, "shapes": 4, "strokes": 0, "subpaths": 4};boards=[];fonts={};warnings=["rectangles with straight, concave or other corner types (imported with round corners)"]
patchy-text-artistic.af dpi=72;spreads=1;model_crc32=b6137f6e;counts={"depth": 1, "nodes": 1, "text_chars": 5, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialMT:400:false"};warnings=[]
patchy-text-caps.af dpi=72;spreads=1;model_crc32=b5527e72;counts={"depth": 1, "nodes": 2, "text_chars": 21, "text_runs": 2, "texts": 2};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font) (2×)"]
patchy-text-frame.af dpi=72;spreads=1;model_crc32=7a2cd32d;counts={"depth": 1, "nodes": 1, "text_chars": 11, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialMT:400:false"};warnings=["paragraph indents or spacing (imported with defaults)"]
patchy-text-indent.af dpi=72;spreads=1;model_crc32=30ee834c;counts={"depth": 1, "nodes": 2, "text_chars": 104, "text_runs": 2, "texts": 2};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font) (2×)", "paragraph indents or spacing (imported with defaults) (2×)"]
patchy-text-para-spacing.af dpi=72;spreads=1;model_crc32=3d580ad2;counts={"depth": 1, "nodes": 1, "text_chars": 13, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)", "paragraph indents or spacing (imported with defaults)"]
patchy-text-rotated.af dpi=72;spreads=1;model_crc32=ad77772a;counts={"depth": 1, "nodes": 1, "text_chars": 7, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)"]
patchy-text-runs.af dpi=72;spreads=1;model_crc32=a8da3052;counts={"depth": 1, "nodes": 1, "text_chars": 7, "text_runs": 5, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false", "Courier New:CourierNewPSMT:400:false", "Segoe UI Emoji:SegoeUIEmoji:400:false", "Times New Roman:TimesNewRomanPSMT:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)", "paragraph indents or spacing (imported with defaults)", "text uses a stored fallback font (imported with that font)"]
patchy-transform.af dpi=72;spreads=1;model_crc32=f8807611;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
patchy-vector-mask.af dpi=72;spreads=1;model_crc32=1851fd90;counts={"depth": 1, "fills": 2, "gradients": 0, "nodes": 2, "segments": 7, "shapes": 2, "strokes": 0, "subpaths": 2, "vector_masks": 1};boards=[];fonts={};warnings=["unknown layers attached to other layers (imported without them)"]
patchy-vector.af dpi=72;spreads=1;model_crc32=b89a91d3;counts={"depth": 1, "fills": 3, "gradients": 0, "nodes": 4, "segments": 17, "shapes": 4, "strokes": 2, "subpaths": 5};boards=[];fonts={};warnings=[]
+30
View File
@@ -0,0 +1,30 @@
//! Shared pinned corpus gate: an installed corpus must be complete and unmodified.
#![allow(dead_code)]
use std::path::PathBuf;
#[path = "../../../../xtask/src/sha256.rs"]
mod sha256;
pub fn pinned() -> Option<PathBuf> {
// This helper is compiled in both affinity and engine integration tests.
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../corpus/affinity");
let required = std::env::var_os("AFFINITY_CORPUS_REQUIRED").is_some_and(|v| v != "0");
if !dir.exists() {
assert!(!required, "required Affinity corpus absent: run `cargo xtask corpus --affinity`");
eprintln!("Affinity corpus absent: run `cargo xtask corpus --affinity` (CI sets AFFINITY_CORPUS_REQUIRED=1)");
return None;
}
assert!(dir.join("SOURCES.md").is_file(), "incomplete Affinity corpus: missing SOURCES.md; run `cargo xtask corpus --affinity`");
let manifest = include_str!("../../../../xtask/affinity-corpus.sha256");
let mut count = 0;
for line in manifest.lines().filter(|l| !l.trim().is_empty()) {
let (digest, name) = line.split_once(" ").expect("malformed pinned Affinity SHA256 manifest");
assert_eq!(digest.len(), 64, "invalid SHA256 for {name}");
let bytes = std::fs::read(dir.join(name)).unwrap_or_else(|e| panic!("missing pinned Affinity file {name}: {e}"));
assert_eq!(sha256::hex(&bytes), digest, "{name}: pinned corpus modified; run `cargo xtask corpus --affinity`");
count += 1;
}
assert!(count >= 4, "pinned Affinity manifest must include the four Affinity 3 .af fixtures");
Some(dir)
}
+63
View File
@@ -0,0 +1,63 @@
//! Structural regression signatures, measured from the pinned files through our own reader.
//! These freeze feature retention; they are not independent visual or compatibility oracles.
//! The model CRC covers every decoded field via its Debug representation (including paths,
//! paint, masks, image pixels and text runs). A deliberate reader/Debug change needs review;
//! input authenticity is independently checked with SHA256 by the corpus gate.
use std::collections::{BTreeMap, BTreeSet};
use vectorcraft_affinity::model::{Document, Kind, Node};
use vectorcraft_affinity::paint::Paint;
pub fn signature(doc: &Document) -> String {
fn visit(nodes: &[Node], counts: &mut BTreeMap<&'static str, usize>, fonts: &mut BTreeSet<String>, boards: &mut Vec<String>, depth: usize) {
for n in nodes {
*counts.entry("nodes").or_default() += 1;
counts.entry("depth").and_modify(|v| *v = (*v).max(depth)).or_insert(depth);
let key = match &n.kind {
Kind::Layer => "layers",
Kind::Group => "groups",
Kind::Artboard { rect, .. } => {
boards.push(format!("{}:{:.4},{:.4},{:.4},{:.4}", n.name, rect.x0, rect.y0, rect.x1, rect.y1));
"artboards"
}
Kind::Shape { path, fills, strokes, .. } => {
*counts.entry("subpaths").or_default() += path.subpaths.len();
*counts.entry("segments").or_default() += path.subpaths.iter().map(|p| p.segments.len()).sum::<usize>();
*counts.entry("fills").or_default() += fills.len();
*counts.entry("strokes").or_default() += strokes.len();
*counts.entry("gradients").or_default() += fills.iter().filter(|p| matches!(p, Paint::Gradient(_))).count();
"shapes"
}
Kind::Text(t) => {
*counts.entry("text_runs").or_default() += t.runs.len();
*counts.entry("text_chars").or_default() += t.runs.iter().map(|r| r.text.chars().count()).sum::<usize>();
for r in &t.runs {
fonts.insert(format!("{}:{}:{}:{}", r.family, r.postscript, r.weight, r.italic));
}
"texts"
}
Kind::Image(_) => "images",
Kind::Unsupported => "unsupported",
};
*counts.entry(key).or_default() += 1;
for (key, yes) in
[("hidden", !n.visible), ("vector_masks", n.mask.is_some()), ("pixel_masks", n.pixel_mask.is_some()), ("opacity", n.opacity < 1.0)]
{
if yes {
*counts.entry(key).or_default() += 1;
}
}
visit(&n.children, counts, fonts, boards, depth + 1);
}
}
let (mut counts, mut fonts, mut boards) = (BTreeMap::new(), BTreeSet::new(), Vec::new());
for spread in &doc.spreads {
visit(&spread.nodes, &mut counts, &mut fonts, &mut boards, 1);
}
let model_crc32 = crc32fast::hash(format!("{:?}", doc.spreads).as_bytes());
format!(
"dpi={};spreads={};model_crc32={model_crc32:08x};counts={counts:?};boards={boards:?};fonts={fonts:?};warnings={:?}",
doc.dpi,
doc.spreads.len(),
doc.warnings
)
}
@@ -0,0 +1,170 @@
//! Original synthetic reader fixtures, shared by native and engine regression tests.
//! These describe the reader's existing schema; no Affinity software or third-party parser was
//! used to create them. They prove recovery/mapping behavior, not Affinity's layout or exporter.
#![allow(dead_code)]
use vectorcraft_affinity::synth::{self, F, Method, tag};
pub fn array_f64(field: &[u8; 4], values: &[f64]) -> F {
let mut bytes = vec![0x8a];
bytes.extend(tag(field).0.to_le_bytes());
bytes.extend((values.len() as u32).to_le_bytes());
bytes.extend(values.iter().flat_map(|v| v.to_le_bytes()));
F::Raw(bytes)
}
pub fn array_i32(field: &[u8; 4], values: &[i32]) -> F {
let mut bytes = vec![0x87];
bytes.extend(tag(field).0.to_le_bytes());
bytes.extend((values.len() as u32).to_le_bytes());
bytes.extend(values.iter().flat_map(|v| v.to_le_bytes()));
F::Raw(bytes)
}
pub fn solid(alpha: f32) -> F {
F::Obj(
tag(b"FilS"),
vec![(
tag(b"Colr"),
F::Obj(tag(b"RGBA"), vec![(tag(b"_col"), F::Struct([0.2f32, 0.4, 0.6, alpha].iter().flat_map(|v| v.to_le_bytes()).collect()))]),
)],
)
}
pub fn attrs(family: &str, postscript: &str, weight: i32, italic: bool, size: f64) -> F {
let mut doubles = vec![0.0; 14];
doubles[0] = size;
doubles[4] = 0.025;
doubles[10] = 1.0;
doubles[13] = 30.0;
F::Obj(
tag(b"GAtt"),
vec![
(
tag(b"DFnt"),
F::Obj(
tag(b"Font"),
vec![
(tag(b"Famy"), F::Str(family.into())),
(tag(b"Post"), F::Str(postscript.into())),
(tag(b"Wegt"), F::I32(weight)),
(tag(b"Ital"), F::Bool(italic)),
],
),
),
(tag(b"Doub"), array_f64(b"Doub", &doubles)),
(tag(b"Ints"), array_i32(b"Ints", &[0, 0, 0, 0, 1])),
(tag(b"Objs"), F::Shared(vec![solid(1.0)])),
],
)
}
pub fn run(end: i32, attrs: Option<F>) -> Vec<(vectorcraft_affinity::stream::Tag, F)> {
let mut fields = vec![(tag(b"Indx"), F::I32(end))];
if let Some(attrs) = attrs {
fields.push((tag(b"Item"), attrs));
}
fields
}
pub fn block(glyphs: F, runs: Vec<Vec<(vectorcraft_affinity::stream::Tag, F)>>, alignments: &[i32]) -> F {
F::Obj(
tag(b"StBl"),
vec![
(tag(b"Glyp"), glyphs),
(tag(b"GAtt"), F::Obj(tag(b"GlAS"), vec![(tag(b"Runs"), F::Objs(tag(b"GlAR"), runs))])),
(
tag(b"PAtt"),
F::Obj(
tag(b"PaAS"),
vec![(
tag(b"Runs"),
F::Objs(
tag(b"PaAR"),
alignments
.iter()
.map(|a| vec![(tag(b"Item"), F::Obj(tag(b"PAtt"), vec![(tag(b"Ints"), array_i32(b"Ints", &[*a]))]))])
.collect(),
),
)],
),
),
],
)
}
pub fn utf8(text: &str) -> F {
F::Obj(tag(b"GStr"), vec![(tag(b"Utf8"), F::Str(text.into()))])
}
pub fn document(blocks: Vec<F>, frame: bool, dpi: f64, transform: [f64; 6]) -> Vec<u8> {
let text = F::Obj(
tag(if frame { b"TxtF" } else { b"TxtA" }),
vec![
(tag(b"StSt"), F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(blocks))])),
(
tag(b"TxtH"),
F::Obj(
tag(if frame { b"FrFr" } else { b"ArFr" }),
vec![(tag(b"FrmB"), F::F64s(vec![10.0, 20.0, 210.0, 120.0])), (tag(b"ArtV"), F::F64(18.0))],
),
),
(tag(b"Xfrm"), F::F64s(transform.to_vec())),
],
);
let mut root = vec![
(tag(b"UVCn"), F::Obj(tag(b"UVCn"), vec![(tag(b"UPPI"), F::F64(dpi))])),
(
tag(b"DocR"),
F::Obj(
tag(b"DocN"),
vec![(
tag(b"Chld"),
F::Shared(vec![F::Obj(
tag(b"Sprd"),
vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 400.0, 300.0])), (tag(b"Chld"), F::Shared(vec![text]))],
)]),
)],
),
),
];
let mut next_id = 1000;
for (_, value) in &mut root {
shared_objects(value, &mut next_id);
}
let stream = synth::stream(&root);
synth::container(&[("doc.dat", &stream, Method::Zlib)], None)
}
pub const IDENTITY: [f64; 6] = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0];
// Shared arrays require definitions/references, whereas inline fields use Obj. Give the original
// fixture objects independent shared IDs without making the fixture descriptions noisy.
fn shared_objects(value: &mut F, next_id: &mut u32) {
match value {
F::Shared(items) => {
for item in items {
if let F::Obj(class, fields) = item {
let class = *class;
let fields = std::mem::take(fields);
*next_id += 1;
*item = F::Def(*next_id, vec![class], fields);
}
shared_objects(item, next_id);
}
}
F::Obj(_, fields) | F::Def(_, _, fields) => {
for (_, value) in fields {
shared_objects(value, next_id);
}
}
F::Objs(_, items) => {
for fields in items {
for (_, value) in fields {
shared_objects(value, next_id);
}
}
}
_ => {}
}
}
+322
View File
@@ -0,0 +1,322 @@
//! Synthetic `.af` version-12 text regression oracles; see support/text_fixture.rs for provenance.
//! Unicode ranges below test the scalar-index contract corroborated by the pinned Affinity 3
//! mixed-run fixture. Broad complex typography still needs independent visual validation.
#![cfg(feature = "synth")]
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod support {
pub mod corpus;
pub mod text_fixture;
}
use support::text_fixture::*;
use vectorcraft_affinity::{
Document, Limits,
model::{Align, Kind, Text},
paint::{Color, Paint},
synth::{F, tag},
};
fn read(blocks: Vec<F>) -> Document {
vectorcraft_affinity::read(&document(blocks, false, 96.0, IDENTITY), Limits::default()).unwrap()
}
fn text(d: &Document) -> &Text {
match &d.spreads[0].nodes[0].kind {
Kind::Text(t) => t,
other => panic!("expected text, got {other:?}"),
}
}
fn content(t: &Text) -> String {
t.runs.iter().map(|r| r.text.as_str()).collect()
}
fn warning(d: &Document, needle: &str) -> bool {
d.warnings.iter().any(|w| w.contains(needle))
}
#[test]
fn mixed_fonts_keep_unicode_and_attribute_boundaries() {
let d = read(vec![block(
utf8("Aé😀e\u{301}אב日本\u{2028}x\u{2029}\0"),
vec![
run(3, Some(attrs("Family One", "FamilyOne-Regular", 400, false, 16.0))),
run(9, Some(attrs("Family Two", "FamilyTwo-BoldItalic", 700, true, 24.0))),
run(13, Some(attrs("Family Three", "FamilyThree-Light", 300, false, 20.0))),
],
&[1],
)]);
let t = text(&d);
assert_eq!(t.runs.iter().map(|r| r.text.as_str()).collect::<Vec<_>>(), ["Aé😀", "e\u{301}אב日本", "\u{2028}x\u{2029}"]);
assert_eq!(content(t), "Aé😀e\u{301}אב日本\u{2028}x\u{2029}");
assert_eq!(
(t.runs[1].family.as_str(), t.runs[1].postscript.as_str(), t.runs[1].weight, t.runs[1].italic),
("Family Two", "FamilyTwo-BoldItalic", 700, true)
);
assert_eq!((t.runs[1].size, t.runs[1].tracking, t.runs[1].leading), (24.0, 0.025, Some(30.0)));
assert!(matches!(t.runs[1].fill, Paint::Solid(Color::Rgb { a: 1.0, .. })));
assert_eq!(t.align, Align::Center);
assert_eq!((t.anchor.x, t.anchor.y), (110.0, 38.0));
assert!(d.warnings.is_empty(), "{:?}", d.warnings);
}
#[test]
fn missing_attributes_and_uncovered_tail_preserve_characters() {
for runs in [vec![], vec![run(2, None)], vec![run(2, Some(attrs("First", "", 400, false, 14.0)))]] {
let d = read(vec![block(utf8("é😀 tail\0"), runs, &[])]);
assert_eq!(content(text(&d)), "é😀 tail");
assert!(warning(&d, "text without character attributes"));
assert!(matches!(text(&d).runs.last().unwrap().fill, Paint::Solid(Color::Gray { v: 0.0, a: 1.0 })));
assert_eq!(text(&d).runs.last().unwrap().size, 16.0); // Recovery default: 12 pt at 96 dpi.
}
}
#[test]
fn malformed_ranges_do_not_discard_the_story() {
let mut missing = run(1, None);
missing.clear();
let d = read(vec![block(
utf8("abcdef"),
vec![missing, run(-1, None), run(3, Some(attrs("First", "", 400, false, 10.0))), run(2, None), run(i32::MAX, None)],
&[],
)]);
assert_eq!(content(text(&d)), "abcdef");
assert_eq!(text(&d).runs[0].text, "abc");
assert!(warning(&d, "invalid text attribute range"));
}
#[test]
fn inline_fields_count_for_ranges_but_are_reported_and_omitted() {
let glyphs = F::Obj(
tag(b"GStr"),
vec![(
tag(b"Mixd"),
F::Objs(
tag(b"MixS"),
vec![
vec![(tag(b"Glys"), F::Shared(vec![F::Obj(tag(b"FldN"), vec![])])), (tag(b"Utf8"), F::Str("é😀".into()))],
vec![(tag(b"Utf8"), F::Str("尾\0".into()))],
],
),
)],
);
let d =
read(vec![block(glyphs, vec![run(3, Some(attrs("First", "", 400, false, 10.0))), run(5, Some(attrs("Second", "", 700, false, 15.0)))], &[])]);
assert_eq!(text(&d).runs.iter().map(|r| r.text.as_str()).collect::<Vec<_>>(), ["é😀", "尾"]);
assert!(warning(&d, "text fields such as page numbers"));
}
#[test]
fn mixed_paragraph_alignments_are_explicitly_reported() {
let d = read(vec![block(utf8("one\u{2029}"), vec![run(4, None)], &[2]), block(utf8("two"), vec![run(3, None)], &[0, 1])]);
assert_eq!(content(text(&d)), "one\u{2029}two");
assert_eq!(text(&d).align, Align::Right);
assert!(warning(&d, "mixed paragraph alignments"));
}
#[test]
fn unknown_alignment_and_invalid_font_weight_have_honest_fallbacks() {
let d = read(vec![block(utf8("test"), vec![run(4, Some(attrs("", "OnlyPostScript", -50, true, f64::NAN)))], &[99])]);
assert_eq!((text(&d).runs[0].weight, text(&d).runs[0].size), (400, 16.0));
assert_eq!(text(&d).align, Align::Left);
assert!(warning(&d, "invalid font weight"));
assert!(warning(&d, "unknown paragraph alignment"));
}
#[test]
fn unreadable_block_does_not_remove_later_readable_text() {
let d = read(vec![F::Obj(tag(b"StBl"), vec![]), block(utf8("still here"), vec![], &[])]);
assert_eq!(content(text(&d)), "still here");
assert!(warning(&d, "a text block without readable characters"));
}
#[test]
fn text_budget_preserves_prefix_and_reports_truncation() {
let d = read(vec![block(utf8(&"é".repeat((1 << 20) + 1)), vec![], &[]), block(utf8("later"), vec![], &[])]);
assert_eq!(content(text(&d)).chars().count(), 1 << 20);
assert!(content(text(&d)).chars().all(|c| c == 'é'));
assert!(warning(&d, "text longer than a million characters"));
}
#[test]
fn boolean_opentype_and_observed_all_caps_selector_are_kept() {
let mut a = attrs("Family", "", 400, false, 12.0);
let settings = F::Obj(
tag(b"OtAt"),
vec![(
tag(b"Setn"),
F::Objs(
tag(b"OTFS"),
vec![
vec![(tag(b"Feat"), F::U32(u32::from_be_bytes(*b"smcp"))), (tag(b"Valu"), F::I32(1))],
vec![(tag(b"Feat"), F::U32(u32::from_be_bytes(*b"liga"))), (tag(b"Valu"), F::I32(0))],
vec![(tag(b"Feat"), F::U32(u32::from_be_bytes(*b"CAP\x01"))), (tag(b"Valu"), F::I32(1))],
vec![(tag(b"Feat"), F::U32(u32::from_be_bytes(*b"salt"))), (tag(b"Valu"), F::I32(2))],
],
),
)],
);
if let F::Obj(_, fields) = &mut a {
let objects = fields.iter_mut().find(|(t, _)| *t == tag(b"Objs")).unwrap();
objects.1 = F::Shared(vec![solid(1.0), F::Null, F::Null, F::Null, F::Null, F::Null, F::Null, settings]);
}
let d = read(vec![block(utf8("Mixed"), vec![run(5, Some(a))], &[])]);
assert_eq!(text(&d).runs[0].features, ["smcp", "-liga"]);
assert!(text(&d).runs[0].all_caps);
assert_eq!(content(text(&d)), "Mixed");
assert!(warning(&d, "non-boolean OpenType settings"));
}
#[test]
fn stored_fallback_font_is_used_only_when_it_names_a_font() {
for (family, expected) in [("", "Requested"), ("Emoji Family", "Emoji Family")] {
let mut a = attrs("Requested", "RequestedPS", 700, true, 12.0);
if let F::Obj(_, fields) = &mut a {
fields.push((tag(b"RFnt"), F::Obj(tag(b"Font"), vec![(tag(b"Famy"), F::Str(family.into())), (tag(b"Wegt"), F::I32(400))])));
}
let d = read(vec![block(utf8("😀"), vec![run(1, Some(a))], &[])]);
assert_eq!(text(&d).runs[0].family, expected);
assert_eq!(warning(&d, "stored fallback font"), !family.is_empty());
}
}
#[test]
fn real_affinity3_text_fixtures_confirm_unicode_and_caps_fields() {
let Some(corpus) = support::corpus::pinned() else { return };
// Files fetched at the commit and sha256 in xtask/affinity-corpus.sha256. No Patchy parser
// source was read; assertions describe this crate's parsing of its published MIT documents.
let load = |name: &str| vectorcraft_affinity::read(&std::fs::read(corpus.join(name)).unwrap(), Limits::default()).unwrap();
let d = load("patchy-text-runs.af");
let t = text(&d);
assert_eq!(t.runs.iter().map(|r| r.text.as_str()).collect::<Vec<_>>(), ["AB", "cd", "É", "😀", "X"]);
assert_eq!(t.runs[3].family, "Segoe UI Emoji");
assert_eq!(t.runs[4].family, "Courier New");
assert!(warning(&d, "stored fallback font"));
let d = load("patchy-text-caps.af");
let texts: Vec<_> = d.spreads[0].nodes.iter().filter_map(|n| if let Kind::Text(t) = &n.kind { Some(t) } else { None }).collect();
assert_eq!(texts.len(), 2);
assert!(texts[0].runs[0].all_caps);
assert_eq!(texts[1].runs[0].features, ["smcp"]);
for name in ["patchy-text-indent.af", "patchy-text-para-spacing.af"] {
let d = load(name);
assert!(warning(&d, "paragraph indents or spacing"), "{name}: {:?}", d.warnings);
}
}
proptest::proptest! {
#![proptest_config(proptest::test_runner::Config::with_cases(64))]
#[test]
fn arbitrary_unicode_survives_damaged_attribute_ranges(
chars in proptest::collection::vec(proptest::prelude::any::<char>(), 0..128),
ends in proptest::collection::vec(-10i32..200, 0..32),
) {
let original: String = chars.into_iter().collect();
let expected: String = original.chars().filter(|c| *c != '\0' && *c != '\u{FFFC}').collect();
let d = read(vec![block(utf8(&original), ends.into_iter().map(|end| run(end, None)).collect(), &[])]);
proptest::prop_assert_eq!(content(text(&d)), expected);
}
}
#[test]
fn partially_missing_attributes_use_visible_fill_but_explicit_no_fill_is_kept() {
let empty = F::Obj(tag(b"GAtt"), vec![]);
let none = F::Obj(tag(b"GAtt"), vec![(tag(b"Objs"), F::Shared(vec![F::Obj(tag(b"FilN"), vec![])]))]);
let d = read(vec![block(utf8("ab"), vec![run(1, Some(empty)), run(2, Some(none))], &[])]);
assert!(matches!(text(&d).runs[0].fill, Paint::Solid(Color::Gray { v: 0.0, a: 1.0 })));
assert_eq!(text(&d).runs[1].fill, Paint::None);
assert_eq!(content(text(&d)), "ab");
assert!(warning(&d, "without readable fill attributes"));
}
#[test]
fn excessive_feature_settings_are_bounded_and_reported() {
let mut a = attrs("Family", "", 400, false, 12.0);
if let F::Obj(_, fields) = &mut a {
let settings = F::Obj(
tag(b"OtAt"),
vec![(
tag(b"Setn"),
F::Objs(
tag(b"OTFS"),
(0..257).map(|_| vec![(tag(b"Feat"), F::U32(u32::from_be_bytes(*b"smcp"))), (tag(b"Valu"), F::I32(1))]).collect(),
),
)],
);
fields.iter_mut().find(|(t, _)| *t == tag(b"Objs")).unwrap().1 =
F::Shared(vec![solid(1.0), F::Null, F::Null, F::Null, F::Null, F::Null, F::Null, settings]);
}
let d = read(vec![block(utf8("x"), vec![run(1, Some(a))], &[])]);
assert_eq!(text(&d).runs[0].features.len(), 256);
assert!(warning(&d, "more than 256 text feature settings"));
}
#[test]
fn shared_font_metadata_amplification_is_bounded_without_losing_characters() {
let mut a = attrs(&"F".repeat(16 * 1024), &"P".repeat(16 * 1024), 400, false, 12.0);
if let F::Obj(_, fields) = &mut a {
let settings = F::Obj(
tag(b"OtAt"),
vec![(
tag(b"Setn"),
F::Objs(
tag(b"OTFS"),
(0..256).map(|_| vec![(tag(b"Feat"), F::U32(u32::from_be_bytes(*b"smcp"))), (tag(b"Valu"), F::I32(1))]).collect(),
),
)],
);
fields.iter_mut().find(|(t, _)| *t == tag(b"Objs")).unwrap().1 =
F::Shared(vec![solid(1.0), F::Null, F::Null, F::Null, F::Null, F::Null, F::Null, settings]);
}
let F::Obj(class, fields) = a else { panic!("attributes must be an object") };
let mut runs = vec![run(1, Some(F::Def(42, vec![class], fields)))];
runs.extend((2..=1000).map(|end| run(end, Some(F::Ref(42)))));
let d = read(vec![block(utf8(&"x".repeat(1000)), runs, &[])]);
let t = text(&d);
assert_eq!(content(t), "x".repeat(1000));
assert_eq!(t.runs.len(), 1000);
let metadata: usize = t
.runs
.iter()
.flat_map(|r| [&r.family, &r.postscript].into_iter().chain(r.features.iter()))
.filter(|s| !s.is_empty())
.map(|s| s.len() + std::mem::size_of::<String>())
.sum();
assert!(metadata <= 16 << 20, "shared metadata was amplified to {metadata} bytes");
assert_eq!(t.runs[0].family.len(), 16 * 1024);
assert_eq!(t.runs[0].features.len(), 256);
assert!(t.runs.last().unwrap().family.is_empty());
assert!(t.runs.last().unwrap().features.is_empty());
assert!(warning(&d, "text attribute budget exceeded"));
}
#[test]
fn shared_gradient_attributes_are_bounded_without_losing_characters() {
let color =
F::Def(43, vec![tag(b"RGBA")], vec![(tag(b"_col"), F::Struct([0.2f32, 0.4, 0.6, 1.0].iter().flat_map(|v| v.to_le_bytes()).collect()))]);
let mut colors = vec![color];
colors.extend((1..1024).map(|_| F::Ref(43)));
let positions = (0..1024).map(|i| [(i as f32) / 1023.0, 0.5].iter().flat_map(|v| v.to_le_bytes()).collect()).collect();
let gradient = F::Obj(
tag(b"FilG"),
vec![(tag(b"Grad"), F::Obj(tag(b"Grad"), vec![(tag(b"Cols"), F::Shared(colors)), (tag(b"Posn"), F::Records(8, positions))]))],
);
let attrs = F::Def(42, vec![tag(b"GAtt")], vec![(tag(b"Objs"), F::Shared(vec![gradient]))]);
let mut runs = vec![run(1, Some(attrs))];
runs.extend((2..=1000).map(|end| run(end, Some(F::Ref(42)))));
let d = read(vec![block(utf8(&"x".repeat(1000)), runs, &[])]);
let t = text(&d);
assert_eq!(content(t), "x".repeat(1000));
assert_eq!(t.runs.len(), 1000);
let stop_count: usize = t
.runs
.iter()
.map(|r| match &r.fill {
Paint::Gradient(g) => g.stops.len(),
_ => 0,
})
.sum();
assert!(stop_count > 0);
assert!(stop_count * std::mem::size_of::<vectorcraft_affinity::paint::Stop>() <= 16 << 20);
let Paint::Gradient(first) = &t.runs[0].fill else { panic!("first gradient must fit") };
assert_eq!(first.stops.len(), 1024);
assert!(matches!(t.runs.last().unwrap().fill, Paint::Solid(_)));
assert!(warning(&d, "text attribute budget exceeded"));
assert!(!warning(&d, "text without character attributes"), "budget recovery must not claim source attributes are absent");
}
+3 -3
View File
@@ -116,7 +116,7 @@ fn every_effect_is_deterministic() {
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}");
assert!(a.as_chunks::<4>().0.iter().all(|p| p[0] <= p[3] && p[1] <= p[3] && p[2] <= p[3]), "{id}");
}
}
@@ -196,9 +196,9 @@ fn lengths_are_document_units() {
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();
let before: u32 = d.as_chunks::<4>().0.iter().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();
let after: u32 = d.as_chunks::<4>().0.iter().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();
+37 -8
View File
@@ -206,6 +206,13 @@ impl Builder {
/// 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>> {
// Register before descending so nested artboards keep their document order.
if let Kind::Artboard { rect, .. } = &n.kind {
boards.push((
if n.name.is_empty() { format!("Artboard {}", boards.len() + 1) } else { n.name.clone() },
to_doc.transform_rect_bbox(rect_of(*rect)),
));
}
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 {
@@ -241,14 +248,18 @@ impl Builder {
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));
Kind::Artboard { path, even_odd, background, strokes, .. } => {
let mut clip = Node::new(
self.doc.alloc_id(),
NodeKind::Path { path: shapes::rectangle(r), rule: FillRule::NonZero, live: None, clipping: true, guide: false },
NodeKind::Path {
path: self.path(path, to_doc),
rule: if *even_odd { FillRule::EvenOdd } else { FillRule::NonZero },
live: None,
clipping: true,
guide: false,
},
);
clip.appearance = self.appearance(background, &[], to_doc);
clip.appearance = self.appearance(background, strokes, to_doc);
let mut kids = vec![Arc::new(clip)];
kids.extend(children);
let mut g = self.base(n, NodeKind::Group { children: kids, clip: true });
@@ -432,9 +443,27 @@ impl Builder {
.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);
let mut style = CharStyle::default();
if !r.family.is_empty() {
style.font_family = r.family.clone();
} else if !r.postscript.is_empty() {
style.font_family = r.postscript.clone();
} else {
self.warn("text without a font name uses the default font");
}
// A family/weight pair cannot identify width variants such as Arial Narrow.
// Resolve a known PostScript identity to the exact installed family and style.
if let Some((family, face_style)) = vectorcraft_text::FontDb::global().by_postscript_name(&r.postscript) {
style.font_family = family;
style.font_style = face_style;
} else {
style.font_style = font_style(r.weight, r.italic);
}
style.features = r.features.clone();
if r.features.iter().any(|feature| !vectorcraft_text::OtFeatures::known_tag(feature)) {
self.warn("unsupported text OpenType features (retained but not rendered)");
}
style.all_caps = r.all_caps;
style.size = r.size * k;
style.leading = r.leading.map(|l| l * k);
style.tracking = r.tracking * 1000.0;
+147
View File
@@ -0,0 +1,147 @@
//! Synthetic version-12 regression documents exercise placement and clipping, independently of
//! the pinned real-file corpus. These are reader fixtures, never claimed as Affinity-authored.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use kurbo::Rect;
use vectorcraft_affinity::synth::{self, F, Method, tag};
use vectorcraft_doc::{Document, Node, NodeKind};
fn field(t: &[u8; 4], f: F) -> (vectorcraft_affinity::stream::Tag, F) {
(tag(t), f)
}
fn red(id: u32) -> F {
F::Def(
id,
vec![tag(b"FDsc")],
vec![field(
b"FDeF",
F::Def(
id + 1,
vec![tag(b"FilS")],
vec![field(
b"Colr",
F::Def(
id + 2,
vec![tag(b"RGBA")],
vec![field(b"_col", F::Struct([1f32, 0.0, 0.0, 1.0].into_iter().flat_map(f32::to_le_bytes).collect()))],
),
)],
),
)],
)
}
fn shape(id: u32, name: &str, board: bool, ellipse: bool, bounds: [f64; 4], transform: [f64; 6], children: Vec<F>) -> F {
F::Def(
id,
vec![tag(b"ShpN")],
vec![
field(b"Desc", F::Str(name.into())),
field(b"ABEn", F::Bool(board)),
field(b"Shpe", F::Def(id + 1, vec![tag(if ellipse { b"ShpE" } else { b"ShNR" })], vec![])),
field(b"ShpB", F::F64s(bounds.to_vec())),
field(b"Xfrm", F::F64s(transform.to_vec())),
field(b"BFFl", F::Shared(vec![red(id + 2)])),
field(b"Chld", F::Shared(children)),
],
)
}
const IDENTITY: [f64; 6] = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0];
fn document(spreads: Vec<Vec<F>>, dpi: f64, method: Method) -> Vec<u8> {
let spreads = spreads
.into_iter()
.enumerate()
.map(|(i, nodes)| {
F::Def(1000 + i as u32, vec![tag(b"Sprd")], vec![field(b"SprB", F::F64s(vec![0.0, 0.0, 200.0, 100.0])), field(b"Chld", F::Shared(nodes))])
})
.collect();
let doc = synth::stream(&[
field(b"UVCn", F::Obj(tag(b"UVCn"), vec![field(b"UPPI", F::F64(dpi))])),
field(b"DocR", F::Def(2000, vec![tag(b"DocN")], vec![field(b"Chld", F::Shared(spreads))])),
]);
synth::container(&[("doc.dat", &doc, method)], None)
}
fn find<'a>(nodes: &'a [std::sync::Arc<Node>], name: &str) -> Option<&'a Node> {
nodes.iter().find_map(|n| {
if n.name.as_deref() == Some(name) && matches!(n.kind, NodeKind::Group { .. }) {
Some(n.as_ref())
} else {
n.children().and_then(|c| find(c, name))
}
})
}
#[test]
fn nested_boards_keep_order_and_negative_origins_at_document_resolution() {
let inner = shape(20, "Inner", true, false, [0.0, 0.0, 20.0, 10.0], [1.0, 0.0, 10.0, 0.0, 1.0, 5.0], vec![]);
let outer = shape(10, "Outer", true, false, [0.0, 0.0, 100.0, 60.0], [1.0, 0.0, -40.0, 0.0, 1.0, -20.0], vec![inner]);
let last = shape(30, "Last", true, false, [0.0, 0.0, 40.0, 80.0], [1.0, 0.0, 150.0, 0.0, 1.0, 0.0], vec![]);
for method in [Method::Stored, Method::Zlib, Method::Zstd] {
let loaded = vectorcraft_engine::cmd::fileio::load("boards.af", &document(vec![vec![outer.clone(), last.clone()]], 144.0, method)).unwrap();
let boards = &loaded.doc.artboards;
assert_eq!(boards.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(), ["Outer", "Inner", "Last"]);
assert_eq!(boards.iter().map(|b| b.id).collect::<Vec<_>>(), [1, 2, 3]);
assert_eq!(boards[0].rect, Rect::new(-20.0, -10.0, 30.0, 20.0));
assert_eq!(boards[1].rect, Rect::new(-15.0, -7.5, -5.0, -2.5));
assert_eq!(boards[2].rect, Rect::new(75.0, 0.0, 95.0, 40.0));
let reopened = vectorcraft_format::load_file(&vectorcraft_format::save_file(&loaded.doc)).unwrap();
assert_eq!(reopened.doc.artboards, loaded.doc.artboards);
assert_eq!(reopened.doc.layers, loaded.doc.layers);
}
}
fn pixel(d: &Document, x: usize, y: usize) -> [u8; 4] {
let img = vectorcraft_render::Renderer::new().render_region(d, d.artboards[0].rect, 1.0, false);
img.to_straight()[(y * img.width as usize + x) * 4..][..4].try_into().unwrap()
}
#[test]
fn rotated_board_clips_to_its_outline_instead_of_its_export_rectangle() {
let child = shape(20, "Oversized", false, false, [-100.0, -100.0, 200.0, 200.0], IDENTITY, vec![]);
let c = std::f64::consts::FRAC_1_SQRT_2;
let board = shape(10, "Diamond", true, false, [0.0, 0.0, 80.0, 80.0], [c, -c, 60.0, c, c, 0.0], vec![child]);
let loaded = vectorcraft_engine::cmd::fileio::load("rotated.af", &document(vec![vec![board]], 72.0, Method::Zstd)).unwrap();
let d = &loaded.doc;
let g = find(&d.layers, "Diamond").unwrap();
let NodeKind::Path { path, clipping: true, .. } = &g.children().unwrap()[0].kind else { panic!() };
let a = &path.subpaths[0].anchors;
assert!((a[0].p.x - 60.0).abs() < 1e-8 && a[0].p.y.abs() < 1e-8);
assert!((a[1].p.x - (60.0 + 80.0 * c)).abs() < 1e-8);
assert_eq!(pixel(d, 2, 2), [0, 0, 0, 0], "bbox corners are outside the diamond");
assert_eq!(pixel(d, 55, 55), [255, 0, 0, 255]);
}
#[test]
fn converted_ellipse_artboard_keeps_curved_clipping() {
let child = shape(20, "Oversized", false, false, [-20.0, -20.0, 120.0, 120.0], IDENTITY, vec![]);
let board = shape(10, "Ellipse", true, true, [0.0, 0.0, 100.0, 100.0], IDENTITY, vec![child]);
let loaded = vectorcraft_engine::cmd::fileio::load("ellipse.af", &document(vec![vec![board]], 72.0, Method::Stored)).unwrap();
assert_eq!(pixel(&loaded.doc, 2, 2), [0, 0, 0, 0]);
assert_eq!(pixel(&loaded.doc, 50, 50), [255, 0, 0, 255]);
}
#[test]
fn invalid_board_keeps_children_and_reports_the_loss() {
let child = shape(20, "Retained", false, false, [1.0, 2.0, 10.0, 20.0], IDENTITY, vec![]);
let board = shape(10, "Invalid", true, false, [0.0, 0.0, 0.0, 100.0], IDENTITY, vec![child]);
let loaded = vectorcraft_engine::cmd::fileio::load("invalid.af", &document(vec![vec![board]], 72.0, Method::Stored)).unwrap();
assert!(loaded.warnings.iter().any(|w| w.contains("artboard without valid bounds")));
assert!(!loaded.preview_only);
assert_eq!(loaded.doc.artboards[0].rect, Rect::new(0.0, 0.0, 200.0, 100.0));
let invalid = find(&loaded.doc.layers, "Invalid").unwrap();
assert_eq!(invalid.children().unwrap()[0].name.as_deref(), Some("Retained"));
}
#[test]
fn several_spreads_preserve_each_board_and_leave_room_between_spreads() {
let a = shape(10, "First", true, false, [0.0, 0.0, 100.0, 60.0], IDENTITY, vec![]);
let b = shape(20, "Second", true, false, [0.0, 0.0, 100.0, 60.0], IDENTITY, vec![]);
let loaded = vectorcraft_engine::cmd::fileio::load("spreads.af", &document(vec![vec![a], vec![b]], 72.0, Method::Stored)).unwrap();
assert_eq!(loaded.doc.artboards.len(), 2);
assert_eq!(loaded.doc.artboards[0].rect, Rect::new(0.0, 0.0, 100.0, 60.0));
assert_eq!(loaded.doc.artboards[1].rect, Rect::new(136.0, 0.0, 236.0, 60.0));
}
+185 -18
View File
@@ -4,14 +4,27 @@
//! (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.
//! Each artboard is checked. Local runs may skip an absent corpus; CI requires the complete
//! pinned corpus with `AFFINITY_CORPUS_REQUIRED=1`. Tests recheck hashes before using inputs.
// 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;
#[path = "../../affinity/tests/support/corpus.rs"]
mod corpus_gate;
/// (file, ceiling). Measured differences when the importer landed are in the comments.
const FILES: &[(&str, f64)] = &[
("patchy-artboards.af", 0.25), // 0.00 across both boards, including overflow clipping
("patchy-rgba8.af", 3.0), // 2.34: embedded thumbnail versus raster sampling
("patchy-rgba16.af", 3.0), // 2.34
("patchy-dpi300.af", 1.5), // 0.87
("patchy-group.af", 0.25), // 0.00
("patchy-transform.af", 1.0), // 0.19
("patchy-vector.af", 1.0), // 0.12
("patchy-lazy-placed.af", 1.0), // 0.50: upstream byte-derived lazy-image regression
("patchy-embedded-jpeg.af", 1.0), // 0.50
("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
@@ -37,8 +50,7 @@ const FILES: &[(&str, f64)] = &[
];
fn corpus() -> Option<PathBuf> {
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../corpus/affinity");
dir.join("SOURCES.md").exists().then_some(dir)
corpus_gate::pinned()
}
/// Mean absolute difference of two RGBA8 images of one size, both composited on white.
@@ -48,7 +60,7 @@ fn difference(a: &[u8], b: &[u8]) -> f64 {
[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)) {
for (p, q) in a.as_chunks::<4>().0.iter().zip(b.as_chunks::<4>().0.iter()) {
let (x, y) = (white(p), white(q));
sum += (0..3).map(|i| (x[i] - y[i]).abs()).sum::<f64>();
n += 3;
@@ -69,23 +81,178 @@ fn public_affinity_documents_render_like_their_affinity_thumbnails() {
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.
// The embedded thumbnail shows the union of the artboards. Check every board,
// including small ones that the previous largest-board-only check omitted.
let union = d.artboards.iter().map(|a| a.rect).reduce(|a, b| a.union(b)).unwrap();
assert!(union.width().is_finite() && union.width() > 0.0);
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}"));
for (index, artboard) in d.artboards.iter().enumerate() {
let board = artboard.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));
assert!(x < thumb.width() && y < thumb.height(), "{name} board {index}: thumbnail crop outside image");
let (w, h) = (crop(board.width()).min(thumb.width() - x), crop(board.height()).min(thumb.height() - y));
assert!(w > 0 && h > 0, "{name} board {index}: empty thumbnail crop");
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} board {index} ({}): {diff:.2} (ceiling {ceiling})", artboard.name);
if diff > *ceiling {
failures.push(format!("{name} board {index}: {diff:.2} > {ceiling}"));
}
}
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
/// Every actual .af fixture must open as editable art, retain its complete native document,
/// and export every artboard. Reimporting our SVG/PDF and comparing our PSD merged pixels
/// checks our export paths; it is not an independent Affinity-versus-PSD fidelity oracle.
#[test]
fn affinity3_all_artboards_survive_native_save_and_export() {
let Some(dir) = corpus() else { return };
let names: Vec<_> = include_str!("../../../xtask/affinity-corpus.sha256")
.lines()
.filter_map(|l| l.split_once(" "))
.map(|(_, name)| name)
.filter(|name| name.ends_with(".af"))
.collect();
for name in names {
let bytes = std::fs::read(dir.join(name)).unwrap();
let loaded = vectorcraft_engine::cmd::fileio::load(name, &bytes).unwrap();
assert!(!loaded.preview_only, "{name}: preview-only import: {:?}", loaded.warnings);
let doc = &loaded.doc;
assert!(!doc.artboards.is_empty(), "{name}: no artboards");
let native = vectorcraft_format::save(doc, false);
let reloaded = vectorcraft_format::load(&native).unwrap();
assert_eq!(serde_json::to_value(&reloaded).unwrap(), serde_json::to_value(doc).unwrap(), "{name}: native save/reload lost document data");
if doc.artboards.len() > 1 {
let params = serde_json::json!({"range": "all", "useArtboards": true, "preserveEditing": false});
let svg = vectorcraft_engine::cmd::fileio::encode_all(doc, "svg", &params).unwrap();
assert_eq!(svg.files.len(), doc.artboards.len(), "{name}: SVG must write every artboard");
assert_eq!(
svg.files.iter().map(|(index, _)| *index).collect::<Vec<_>>(),
(0..doc.artboards.len()).map(Some).collect::<Vec<_>>(),
"{name}: SVG artboard order"
);
let pdf = vectorcraft_engine::cmd::fileio::encode(doc, "pdf", &params).unwrap();
assert_eq!(
vectorcraft_pdf::info(&pdf, None).unwrap().pages.len(),
doc.artboards.len(),
"{name}: PDF must write every artboard as a page"
);
}
for (index, board) in doc.artboards.iter().enumerate() {
assert!(
board.rect.width().is_finite() && board.rect.width() > 0.0 && board.rect.height().is_finite() && board.rect.height() > 0.0,
"{name} board {index}: invalid bounds"
);
let scale = 128.0 / board.rect.width().max(board.rect.height());
let params =
serde_json::json!({"artboard": index, "useArtboards": true, "scale": scale, "background": "transparent", "preserveEditing": false});
let svg = vectorcraft_engine::cmd::fileio::encode(doc, "svg", &params).unwrap();
let svg = vectorcraft_svg::import(std::str::from_utf8(&svg).unwrap()).unwrap();
assert_eq!(svg.artboards.len(), 1, "{name} board {index}: SVG page count");
assert!((svg.artboards[0].rect.width() - board.rect.width()).abs() < 0.01, "{name} board {index}: SVG width");
assert!((svg.artboards[0].rect.height() - board.rect.height()).abs() < 0.01, "{name} board {index}: SVG height");
let pdf = vectorcraft_engine::cmd::fileio::encode(doc, "pdf", &params).unwrap();
let info = vectorcraft_pdf::info(&pdf, None).unwrap();
assert_eq!(info.pages.len(), 1, "{name} board {index}: PDF page count");
assert!((info.pages[0].width - board.rect.width()).abs() < 0.01, "{name} board {index}: PDF width");
assert!((info.pages[0].height - board.rect.height()).abs() < 0.01, "{name} board {index}: PDF height");
assert_eq!(vectorcraft_pdf::import(&pdf).unwrap().artboards.len(), 1, "{name} board {index}: PDF reimport");
let psd = vectorcraft_engine::cmd::fileio::encode(doc, "psd", &params).unwrap();
let png = vectorcraft_engine::cmd::fileio::encode(doc, "png", &params).unwrap();
let png = image::load_from_memory(&png).unwrap().to_rgba8();
let (merged, has_alpha) = psd_merged(&psd);
assert_eq!(merged.dimensions(), png.dimensions(), "{name} board {index}: PSD image dimensions");
// PSD merged RGB is stored on a white matte for readers that ignore alpha;
// the separate alpha plane, when present, keeps the original coverage.
for (pixel, (p, q)) in png.pixels().zip(merged.pixels()).enumerate() {
if has_alpha {
assert_eq!(p[3], q[3], "{name} board {index} pixel {pixel}: PSD alpha");
}
let alpha = u32::from(p[3]);
for channel in 0..3 {
let expected = ((u32::from(p[channel]) * alpha + 255 * (255 - alpha) + 127) / 255) as u8;
assert!(
q[channel].abs_diff(expected) <= 1,
"{name} board {index} pixel {pixel} channel {channel}: PSD white-matted RGB {} differs from PNG on white {expected}",
q[channel]
);
}
}
eprintln!("{name} board {index}: native/SVG/PDF/PSD verified (import warnings: {:?})", loaded.warnings);
}
}
}
/// Minimal test-only decoder written from the public PSD file-format layout:
/// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/
/// It independently reads the merged RGB planes and PackBits rows, skipping metadata/layers.
fn psd_merged(bytes: &[u8]) -> (image::RgbaImage, bool) {
let be16 = |at: usize| u16::from_be_bytes(bytes[at..at + 2].try_into().unwrap());
let be32 = |at: usize| u32::from_be_bytes(bytes[at..at + 4].try_into().unwrap()) as usize;
assert_eq!(&bytes[..4], b"8BPS");
assert_eq!(be16(4), 1);
assert_eq!(&bytes[6..12], &[0; 6]);
let channels = usize::from(be16(12));
assert!((3..=4).contains(&channels));
let (height, width) = (be32(14), be32(18));
assert_eq!(be16(22), 8);
assert_eq!(be16(24), 3);
let mut at = 26;
for _ in 0..3 {
at += 4 + be32(at);
}
let compression = be16(at);
at += 2;
let mut planes = vec![vec![0; width * height]; channels];
match compression {
0 => {
for plane in &mut planes {
plane.copy_from_slice(&bytes[at..at + width * height]);
at += width * height;
}
}
1 => {
let row_lengths: Vec<_> = (0..channels * height).map(|i| usize::from(be16(at + i * 2))).collect();
at += channels * height * 2;
for (channel, plane) in planes.iter_mut().enumerate() {
for row in 0..height {
let end = at + row_lengths[channel * height + row];
let mut decoded = Vec::new();
while at < end {
let control = bytes[at] as i8;
at += 1;
match control {
0..=127 => {
let n = control as usize + 1;
assert!(at + n <= end);
decoded.extend_from_slice(&bytes[at..at + n]);
at += n;
}
-127..=-1 => {
assert!(at < end);
decoded.extend(std::iter::repeat_n(bytes[at], (1i16 - i16::from(control)) as usize));
at += 1;
}
-128 => {}
}
}
assert_eq!(decoded.len(), width);
plane[row * width..(row + 1) * width].copy_from_slice(&decoded);
}
}
}
_ => panic!("unexpected PSD compression {compression}"),
}
assert_eq!(at, bytes.len(), "PSD merged planes must consume the complete image section");
let image = image::RgbaImage::from_fn(width as u32, height as u32, |x, y| {
let i = y as usize * width + x as usize;
image::Rgba([planes[0][i], planes[1][i], planes[2][i], planes.get(3).map_or(255, |p| p[i])])
});
(image, channels == 4)
}
+198
View File
@@ -0,0 +1,198 @@
//! Editable text import regressions on original synthetic version-12 `.af` streams and pinned
//! public Affinity 3 files. Font rendering depends on installed fonts; these are structure and
//! conversion oracles, not claims of pixel-identical typography.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
#[path = "../../affinity/tests/support/corpus.rs"]
mod corpus;
#[path = "../../affinity/tests/support/text_fixture.rs"]
mod fixture;
use fixture::*;
use vectorcraft_affinity::synth::{F, tag};
use vectorcraft_doc::{
Document, Node, NodeKind, TextObject,
text::{CharStyle, Justify, TextKind},
};
use vectorcraft_engine::cmd::fileio;
fn texts(d: &Document) -> Vec<&TextObject> {
fn walk<'a>(n: &'a Node, out: &mut Vec<&'a TextObject>) {
if let NodeKind::Text(t) = &n.kind {
out.push(t);
}
if let Some(children) = n.children() {
for child in children {
walk(child, out);
}
}
}
let mut out = Vec::new();
for n in &d.layers {
walk(n, &mut out);
}
out
}
fn import(blocks: Vec<F>, frame: bool, dpi: f64, transform: [f64; 6]) -> fileio::Loaded {
let loaded = fileio::load("original-text.af", &document(blocks, frame, dpi, transform)).unwrap();
assert!(!loaded.preview_only);
loaded
}
#[test]
fn unicode_mixed_fonts_line_breaks_and_styles_remain_editable_after_save() {
let loaded = import(
vec![block(
utf8("é😀\u{2028}ab\u{2029}尾\0"),
vec![run(2, Some(attrs("First", "FirstPS", 700, true, 24.0))), run(8, Some(attrs("Second", "SecondPS", 300, false, 20.0)))],
&[1],
)],
false,
96.0,
[2.0, 0.0, 30.0, 0.0, 2.0, 40.0],
);
let t = texts(&loaded.doc)[0];
assert_eq!(t.runs.iter().map(|r| r.text.as_str()).collect::<Vec<_>>(), ["é😀", "\nab\n尾"]);
assert_eq!(t.runs[0].style.font_family, "First");
assert_eq!(t.runs[0].style.font_style, "Bold Italic");
assert_eq!(t.runs[1].style.font_style, "Light");
assert_eq!((t.runs[0].style.size, t.runs[0].style.leading, t.runs[0].style.tracking), (36.0, Some(45.0), 25.0));
assert_eq!(t.para.justify, Justify::Center);
assert!(matches!(t.kind, TextKind::Point));
assert_eq!(t.xf.as_coeffs(), [1.0, 0.0, 0.0, 1.0, 187.5, 87.0]);
let saved = vectorcraft_format::save_file(&loaded.doc);
let restored = vectorcraft_format::load(&saved).unwrap();
assert_eq!(texts(&restored)[0], t);
}
#[test]
fn absent_font_name_uses_named_default_and_postscript_name_is_preserved() {
let loaded =
import(vec![block(utf8("ab"), vec![run(1, Some(attrs("", "OnlyPostScript", 400, false, 12.0))), run(2, None)], &[])], false, 72.0, IDENTITY);
let t = texts(&loaded.doc)[0];
assert_eq!(t.runs[0].style.font_family, "OnlyPostScript");
assert_eq!(t.runs[1].style.font_family, CharStyle::default().font_family);
assert_eq!(t.runs[1].style.fill.color().unwrap().to_rgb(), [0.0, 0.0, 0.0]);
assert!(loaded.warnings.iter().any(|w| w.contains("without a font name")));
assert!(loaded.warnings.iter().any(|w| w.contains("without character attributes")));
}
#[test]
fn frame_type_keeps_dimensions_alignment_and_unit_conversion() {
let loaded = import(vec![block(utf8("text"), vec![run(4, Some(attrs("Frame", "", 500, false, 20.0)))], &[3])], true, 144.0, IDENTITY);
let t = texts(&loaded.doc)[0];
assert_eq!(t.runs[0].style.size, 10.0);
assert_eq!(t.runs[0].style.font_style, "Medium");
assert_eq!(t.para.justify, Justify::JustifyLeft);
let TextKind::Area { frame } = &t.kind else { panic!("expected area type") };
assert_eq!(frame.bounds().unwrap().width(), 100.0);
assert_eq!(frame.bounds().unwrap().height(), 50.0);
}
#[test]
fn each_font_weight_and_italic_maps_to_the_expected_style() {
let cases = [
(100, "Thin"),
(200, "ExtraLight"),
(300, "Light"),
(400, "Regular"),
(500, "Medium"),
(600, "SemiBold"),
(700, "Bold"),
(800, "ExtraBold"),
(900, "Black"),
];
for (weight, expected) in cases {
for italic in [false, true] {
let loaded = import(vec![block(utf8("x"), vec![run(1, Some(attrs("Family", "", weight, italic, 12.0)))], &[])], false, 72.0, IDENTITY);
let style = &texts(&loaded.doc)[0].runs[0].style.font_style;
let expected = if !italic {
expected.into()
} else if expected == "Regular" {
"Italic".into()
} else {
format!("{expected} Italic")
};
assert_eq!(*style, expected);
}
}
}
#[test]
fn caps_and_feature_overrides_pass_through_to_editable_type() {
let mut a = attrs("Family", "", 400, false, 12.0);
if let F::Obj(_, fields) = &mut a {
fields.iter_mut().find(|(t, _)| *t == tag(b"Objs")).unwrap().1 = F::Shared(vec![
solid(1.0),
F::Null,
F::Null,
F::Null,
F::Null,
F::Null,
F::Null,
F::Obj(
tag(b"OtAt"),
vec![(
tag(b"Setn"),
F::Objs(
tag(b"OTFS"),
vec![
vec![(tag(b"Feat"), F::U32(u32::from_be_bytes(*b"CAP\x01"))), (tag(b"Valu"), F::I32(1))],
vec![(tag(b"Feat"), F::U32(u32::from_be_bytes(*b"liga"))), (tag(b"Valu"), F::I32(0))],
vec![(tag(b"Feat"), F::U32(u32::from_be_bytes(*b"ss01"))), (tag(b"Valu"), F::I32(1))],
],
),
)],
),
]);
}
let loaded = import(vec![block(utf8("Mixed"), vec![run(5, Some(a))], &[])], false, 72.0, IDENTITY);
let t = texts(&loaded.doc)[0];
assert_eq!(t.runs[0].text, "Mixed");
assert!(t.runs[0].style.all_caps);
assert_eq!(t.runs[0].style.features, ["-liga", "ss01"]);
assert!(loaded.warnings.iter().any(|w| w.contains("retained but not rendered")));
let restored = vectorcraft_format::load(&vectorcraft_format::save_file(&loaded.doc)).unwrap();
assert_eq!(texts(&restored)[0], t);
}
#[test]
fn pinned_affinity3_text_opens_as_editable_unicode_with_resolved_emoji_font() {
let Some(corpus) = corpus::pinned() else { return };
let load = |name: &str| {
let loaded = fileio::load(name, &std::fs::read(corpus.join(name)).unwrap()).unwrap();
assert!(!loaded.preview_only);
loaded
};
let loaded = load("patchy-text-runs.af");
let t = texts(&loaded.doc)[0];
assert_eq!(t.runs.iter().map(|r| r.text.as_str()).collect::<String>(), "ABcdÉ😀X");
assert_eq!(t.runs[3].style.font_family, "Segoe UI Emoji");
assert_eq!(t.runs[4].style.font_family, "Courier New");
let loaded = load("patchy-text-caps.af");
let ts = texts(&loaded.doc);
assert!(ts[0].runs[0].style.all_caps);
assert_eq!(ts[1].runs[0].style.features, ["smcp"]);
for name in ["patchy-text-artistic.af", "patchy-text-frame.af", "patchy-text-rotated.af", "patchy-text-indent.af", "patchy-text-para-spacing.af"]
{
let loaded = load(name);
assert!(!texts(&loaded.doc).is_empty(), "{name}");
let restored = vectorcraft_format::load(&vectorcraft_format::save_file(&loaded.doc)).unwrap();
assert_eq!(texts(&restored), texts(&loaded.doc), "{name}");
}
}
#[test]
fn installed_postscript_identity_overrides_ambiguous_family_and_weight() {
// This is an OS font metadata check. No font file is committed or required; the structural
// fallback cases above always run, including when the optional font repository is absent.
let db = vectorcraft_text::FontDb::global();
let Some((family, face_style)) = db.by_postscript_name("DejaVuSans") else {
eprintln!("DejaVuSans is not installed; exact PostScript face test skipped");
return;
};
let loaded =
import(vec![block(utf8("x"), vec![run(1, Some(attrs("Ambiguous Family", "DejaVuSans", 900, true, 12.0)))], &[])], false, 72.0, IDENTITY);
let style = &texts(&loaded.doc)[0].runs[0].style;
assert_eq!(style.font_family, family);
assert_eq!(style.font_style, face_style);
}
+42 -25
View File
@@ -556,25 +556,41 @@ fn lightness(p: &[u8; 4]) -> u8 {
((on_white(p[0]) * 299 + on_white(p[1]) * 587 + on_white(p[2]) * 114) / 1000).min(255) as u8
}
/// How much two documents draw differently (0 to 1), `a` on `ra` and `b` on `rb` (rectangles of
/// the same size), each seen on white. Pixels are compared by lightness: the page of an RGB
/// document is written in the colours' CMYK equivalents, and what the comparison looks for is art
/// that is missing or out of place.
fn difference(a: &Document, ra: Rect, b: &Document, rb: Rect) -> f64 {
// The page doesn't print what isn't printed.
let printed = as_printed(b);
let b = printed.as_ref().unwrap_or(b);
let scale = (400.0 / ra.width().max(ra.height()).max(1.0)).min(4.0);
let mut r = vectorcraft_render::Renderer::new();
// Drawn on nothing and then seen on white: blending modes have no page to blend with, as in
// the app that wrote the file.
let (x, y) = (r.render_region(a, ra, scale, false), r.render_region(b, rb, scale, false));
if x.width != y.width || x.height != y.height {
return 1.0;
/// The page drawn once, to weigh the layers' art against it: [`Self::difference`] draws the layers
/// with a renderer of their own, which keeps their decoded images between draws (the page's images
/// can share their keys and not their pixels, so the two don't share one).
struct PageView {
page: vectorcraft_render::Rendered,
layers: vectorcraft_render::Renderer,
scale: f64,
}
impl PageView {
fn new(frame: &Frame<'_>) -> Self {
let ra = frame.page_rect;
let scale = (400.0 / ra.width().max(ra.height()).max(1.0)).min(4.0);
// Drawn on nothing and then seen on white: blending modes have no page to blend with, as in
// the app that wrote the file.
let page = vectorcraft_render::Renderer::new().render_region(frame.page, ra, scale, false);
Self { page, layers: vectorcraft_render::Renderer::new(), scale }
}
/// How much `b` on `rb` (a rectangle the page's size) draws differently from the page (0 to 1),
/// each seen on white. Pixels are compared by lightness: the page of an RGB document is written in
/// the colours' CMYK equivalents, and what the comparison looks for is art that is missing or out
/// of place.
fn difference(&mut self, b: &Document, rb: Rect) -> f64 {
// The page doesn't print what isn't printed.
let printed = as_printed(b);
let b = printed.as_ref().unwrap_or(b);
let (x, y) = (&self.page, self.layers.render_region(b, rb, self.scale, false));
if x.width != y.width || x.height != y.height {
return 1.0;
}
let differing =
x.pixels.as_chunks::<4>().0.iter().zip(y.pixels.as_chunks::<4>().0).filter(|(p, q)| lightness(p).abs_diff(lightness(q)) > 48).count();
differing as f64 / (f64::from(x.width) * f64::from(x.height)).max(1.0)
}
let differing =
x.pixels.as_chunks::<4>().0.iter().zip(y.pixels.as_chunks::<4>().0).filter(|(p, q)| lightness(p).abs_diff(lightness(q)) > 48).count();
differing as f64 / (f64::from(x.width) * f64::from(x.height)).max(1.0)
}
/// More of the page than this differing between the layers' art and the page's own, and the
@@ -671,11 +687,12 @@ fn build(data: &[u8], visible: Option<&Document>, page: Page) -> Result<(Documen
}
match frame {
Some(frame) => {
let unseen = prune_unseen(&frame, &mut doc);
let mut view = PageView::new(&frame);
let unseen = prune_unseen(&frame, &mut view, &mut doc);
if unseen > 0 {
notes.push(format!("{unseen} {UNSEEN_TEXT}"));
}
notes.extend(compare(&frame, &doc)?);
notes.extend(compare(&frame, &mut view, &doc)?);
}
None => unmark(&mut doc.layers, true, &mut vec![]),
}
@@ -729,14 +746,14 @@ fn take_out(nodes: &mut Vec<Arc<Node>>, id: NodeId, hide: bool) -> bool {
/// has no type to match them, and that the layers look no worse without, are not drawn by the app
/// that wrote the file (which paints every shown text object; it keeps the type of what was turned
/// to outlines): they are taken out. How many.
fn prune_unseen(frame: &Frame<'_>, doc: &mut Document) -> usize {
fn prune_unseen(frame: &Frame<'_>, view: &mut PageView, doc: &mut Document) -> usize {
let mut found = vec![];
unmark(&mut doc.layers, true, &mut found);
let rect = frame.rect;
if found.is_empty() || found.len() > MAX_WEIGHED {
return 0;
}
let mut with = difference(frame.page, frame.page_rect, doc, rect);
let mut with = view.difference(doc, rect);
let mut removed = 0;
for (id, bounds) in found {
if bounds.is_none_or(|b| b.intersect(rect).is_zero_area()) {
@@ -746,7 +763,7 @@ fn prune_unseen(frame: &Frame<'_>, doc: &mut Document) -> usize {
if !take_out(&mut without.layers, id, true) {
continue;
}
let d = difference(frame.page, frame.page_rect, &without, rect);
let d = view.difference(&without, rect);
if d - with <= UNSEEN_MARGIN {
take_out(&mut doc.layers, id, false);
with = d;
@@ -758,8 +775,8 @@ fn prune_unseen(frame: &Frame<'_>, doc: &mut Document) -> usize {
/// Do the layers' art and the page's own look alike? An error says they don't; `Ok` has the note
/// to give when they differ a little.
fn compare(frame: &Frame<'_>, layered: &Document) -> Result<Option<String>, String> {
let diff = difference(frame.page, frame.page_rect, layered, frame.rect);
fn compare(frame: &Frame<'_>, view: &mut PageView, layered: &Document) -> Result<Option<String>, String> {
let diff = view.difference(layered, frame.rect);
if diff > MAX_DIFFERENCE {
return Err(format!("the art on them differs from the page's in {:.0}% of it", diff * 100.0));
}
+28 -13
View File
@@ -14,21 +14,36 @@ fn a_fresh_session_resolves_pdf_fonts_to_installed_families() {
let probe = FontDb::with_font_dirs(system_font_dirs());
probe.load_system_fonts();
let installed = probe.families();
// A family whose PDF name is its name without the spaces (as "TimesNewRoman").
let Some(family) = installed.iter().find(|f| !bundled.contains(f) && f.contains(' ') && f.chars().all(|c| c.is_ascii_alphanumeric() || c == ' '))
else {
// Families whose PDF name is their name without the spaces (as "TimesNewRoman"). The font
// isn't embedded, so the page draws a stand-in; text stays live only where the installed
// font's glyphs match it, as a plain sans-serif's do and a condensed display face's don't:
// which family that is depends on the machine's fonts, so the first of them to do is checked.
let candidates: Vec<&String> = installed
.iter()
.filter(|f| !bundled.contains(f) && f.contains(' ') && f.chars().all(|c| c.is_ascii_alphanumeric() || c == ' '))
.take(200)
.collect();
if candidates.is_empty() {
eprintln!("no system fonts installed: nothing to check");
return;
}
let import = |family: &str| {
let resources = format!("/Font << /F1 << /Type /Font /Subtype /TrueType /BaseFont /{} >> >>", family.replace(' ', ""));
let page = PdfPage { resources, ..PdfPage::new(100.0, 100.0, "BT /F1 12 Tf 10 30 Td (Hi) Tj ET") };
let r = import_with_report(&pdf_with(&[page], &[], None), &ImportOptions::default()).unwrap();
let mut families = vec![];
r.document.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
families.extend(t.runs.iter().map(|r| r.style.font_family.clone()));
}
});
(families, r.warnings)
};
let resources = format!("/Font << /F1 << /Type /Font /Subtype /TrueType /BaseFont /{} >> >>", family.replace(' ', ""));
let page = PdfPage { resources, ..PdfPage::new(100.0, 100.0, "BT /F1 12 Tf 10 30 Td (Hi) Tj ET") };
let r = import_with_report(&pdf_with(&[page], &[], None), &ImportOptions::default()).unwrap();
let mut families = vec![];
r.document.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
families.extend(t.runs.iter().map(|r| r.style.font_family.clone()));
}
let found = candidates.iter().find_map(|family| {
let (families, warnings) = import(family);
(!families.is_empty()).then_some((family, families, warnings))
});
assert_eq!(families, std::slice::from_ref(family));
assert!(!r.warnings.iter().any(|w| w.contains(family.as_str())), "{:?}", r.warnings);
let (family, families, warnings) = found.expect("one of the installed families stays live text");
assert_eq!(families, std::slice::from_ref(*family));
assert!(!warnings.iter().any(|w| w.contains(family.as_str())), "{warnings:?}");
}
+33 -9
View File
@@ -773,16 +773,37 @@ fn enumerate_faces(data: &[u8]) -> Vec<(u32, FaceStyle)> {
.collect()
}
/// The font files held by the file at `path`: its contents, or for a suitcase font the fonts in
/// its resource fork ([`crate::suitcase`]). None when it can't be read.
#[cfg(not(target_arch = "wasm32"))]
fn font_files(path: &Path) -> Vec<Vec<u8>> {
if crate::suitcase::is_suitcase(path) {
return crate::suitcase::read_fonts(path);
}
std::fs::read(path).into_iter().collect()
}
/// The styles of every face in the font file at `path` ([`face_styles`]), reading only its table
/// directories and `name` and `fvar` tables: a scan opens hundreds of font files, many of them
/// megabytes long.
#[cfg(not(target_arch = "wasm32"))]
fn file_face_names(path: &Path) -> Vec<FaceStyle> {
use std::io::{Read, Seek, SeekFrom};
if crate::suitcase::is_suitcase(path) {
return crate::suitcase::read_fonts(path).into_iter().flat_map(|font| reader_face_names(std::io::Cursor::new(font))).collect();
}
match std::fs::File::open(path) {
Ok(file) => reader_face_names(file),
Err(_) => vec![],
}
}
/// [`file_face_names`] for the font file `file` reads.
#[cfg(not(target_arch = "wasm32"))]
fn reader_face_names(mut file: impl std::io::Read + std::io::Seek) -> Vec<FaceStyle> {
use std::io::SeekFrom;
/// Caps on what a (possibly damaged) file can make the scan read.
const MAX_FACES: u32 = 256;
const MAX_NAME_TABLE: u32 = 1 << 20;
let Ok(mut file) = std::fs::File::open(path) else { return vec![] };
let mut read_at = |offset: u64, len: usize| -> Option<Vec<u8>> {
let mut buf = vec![0; len];
file.seek(SeekFrom::Start(offset)).ok()?;
@@ -1328,9 +1349,10 @@ impl FontDb {
}
for (p, named) in files {
// A file named by itself is a font whatever its name (font services keep fonts in
// files without an extension); one of a folder's only with a font's extension.
// files without an extension); one of a folder's only with a font's extension, or
// a suitcase font (whose fonts are in its resource fork, whatever its name).
let ext = p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase());
if !named && !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) {
if !named && !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) && !crate::suitcase::is_suitcase(&p) {
continue;
}
for FaceStyle { family, style, keys, weight, italic, traits, .. } in file_face_names(&p) {
@@ -1451,7 +1473,7 @@ impl FontDb {
paths.dedup();
let mut any = false;
for p in paths {
if let Ok(data) = std::fs::read(&p) {
for data in font_files(&p) {
any |= self.add_font_from(data, Some(&p)) > 0;
}
}
@@ -1592,10 +1614,12 @@ impl FontDb {
if std::fs::metadata(&p).map(|m| m.len() > 40 << 20).unwrap_or(true) {
continue;
}
let Ok(data) = std::fs::read(&p) else { continue };
let hit = enumerate_faces(&data).iter().any(|(i, _)| skrifa::FontRef::from_index(&data, *i).is_ok_and(|f| f.charmap().map(c).is_some()));
if hit && self.add_font_from(data, Some(&p)) > 0 && covered(self) {
return true;
for data in font_files(&p) {
let hit =
enumerate_faces(&data).iter().any(|(i, _)| skrifa::FontRef::from_index(&data, *i).is_ok_and(|f| f.charmap().map(c).is_some()));
if hit && self.add_font_from(data, Some(&p)) > 0 && covered(self) {
return true;
}
}
}
self.sys.lock().unwrap_or_else(|e| e.into_inner()).misses.insert(c);
+2
View File
@@ -18,6 +18,8 @@ mod fontdb;
pub mod hyphen;
mod layout;
mod shape;
#[cfg(not(target_arch = "wasm32"))]
mod suitcase;
#[cfg(any(test, feature = "test-fonts"))]
pub mod test_fonts;
pub mod thread;
+151
View File
@@ -0,0 +1,151 @@
//! Suitcase fonts: classic Mac OS font files whose data fork is empty and whose fonts are `sfnt`
//! resources in the resource fork (Microsoft Office installs its fonts this way in
//! `/Library/Fonts/Microsoft`). macOS lists them as installed; a reader of the file's data finds
//! nothing, so the scan reads the resource fork. The fork's layout is Apple's published resource
//! file format (Inside Macintosh: More Macintosh Toolbox, "Resource Manager").
use std::path::Path;
/// Caps on what a (possibly damaged) fork can make the scan read.
const MAX_FORK: u64 = 256 << 20;
const MAX_TYPES: usize = 1 << 12;
const MAX_FONTS: usize = 256;
fn be16(b: &[u8], at: usize) -> Option<usize> {
b.get(at..at.checked_add(2)?).and_then(|s| s.try_into().ok()).map(|s| u16::from_be_bytes(s) as usize)
}
fn be32(b: &[u8], at: usize) -> Option<usize> {
b.get(at..at.checked_add(4)?).and_then(|s| s.try_into().ok()).map(|s| u32::from_be_bytes(s) as usize)
}
/// The font files (`sfnt` resources) of a resource fork, in the order its map lists them. A
/// fork that isn't well formed gives the fonts read before the damage, or none.
pub(crate) fn sfnt_resources(fork: &[u8]) -> Vec<&[u8]> {
let mut fonts = Vec::new();
let _ = collect(fork, &mut fonts);
fonts
}
fn collect<'a>(fork: &'a [u8], fonts: &mut Vec<&'a [u8]>) -> Option<()> {
// The header: where the data and the map start.
let data = be32(fork, 0)?;
let map = fork.get(be32(fork, 4)?..)?;
// The map: its type list starts at the offset kept 24 bytes in, and counts from the map.
let types = be16(map, 24)?;
// Counts are stored less one, so a list of none is 0xFFFF.
let n_types = (be16(map, types)? + 1) & 0xFFFF;
let mut total = 0usize;
for t in 0..n_types.min(MAX_TYPES) {
let at = types + 2 + t * 8;
if map.get(at..at + 4)? != b"sfnt" {
continue;
}
let count = (be16(map, at + 4)? + 1) & 0xFFFF;
// Each reference: its data's offset is the low three bytes of the word 4 bytes in.
let refs = types + be16(map, at + 6)?;
for r in 0..count {
let start = data.checked_add(be32(map, refs + r * 12 + 4)? & 0x00FF_FFFF)?;
let len = be32(fork, start)?;
// References can share data: cap the total too, as every font is copied out.
total = total.checked_add(len)?;
if fonts.len() >= MAX_FONTS || total as u64 > MAX_FORK {
return Some(());
}
fonts.push(fork.get(start + 4..start.checked_add(4)?.checked_add(len)?)?);
}
}
Some(())
}
/// The fonts of the suitcase font at `path`, read from its resource fork; none for a file
/// without one, and off macOS, the only place such a fork is a part of a file.
pub(crate) fn read_fonts(path: &Path) -> Vec<Vec<u8>> {
let Some(fork) = read_fork(path) else { return vec![] };
sfnt_resources(&fork).into_iter().map(<[u8]>::to_vec).collect()
}
/// Whether the file at `path` is a suitcase font: nothing in its data fork and something in its
/// resource fork.
pub(crate) fn is_suitcase(path: &Path) -> bool {
// Off macOS no file has a fork: don't look at every font file twice.
cfg!(target_os = "macos") && std::fs::metadata(path).is_ok_and(|m| m.is_file() && m.len() == 0) && fork_len(path) > 0
}
#[cfg(target_os = "macos")]
fn fork_path(path: &Path) -> std::path::PathBuf {
path.join("..namedfork/rsrc")
}
#[cfg(target_os = "macos")]
fn fork_len(path: &Path) -> u64 {
std::fs::metadata(fork_path(path)).map_or(0, |m| m.len())
}
#[cfg(target_os = "macos")]
fn read_fork(path: &Path) -> Option<Vec<u8>> {
use std::io::Read;
let mut fork = Vec::new();
std::fs::File::open(fork_path(path)).ok()?.take(MAX_FORK).read_to_end(&mut fork).ok()?;
Some(fork)
}
#[cfg(not(target_os = "macos"))]
fn fork_len(_: &Path) -> u64 {
0
}
#[cfg(not(target_os = "macos"))]
fn read_fork(_: &Path) -> Option<Vec<u8>> {
let _ = MAX_FORK;
None
}
/// A resource fork holding `fonts` as `sfnt` resources and one other resource (a `vers`), as
/// a font utility writes them.
#[cfg(test)]
pub(crate) fn fork_of(fonts: &[Vec<u8>]) -> Vec<u8> {
let mut data = Vec::new();
let mut offsets = Vec::new();
for body in fonts.iter().map(Vec::as_slice).chain([&b"1.0"[..]]) {
offsets.push(data.len() as u32);
data.extend_from_slice(&(body.len() as u32).to_be_bytes());
data.extend_from_slice(body);
}
let n = fonts.len();
// The type list: `vers` (1 resource) then `sfnt`, each entry pointing at its references.
let list = 2 + 2 * 8;
let mut types = Vec::new();
types.extend_from_slice(&1u16.to_be_bytes());
types.extend_from_slice(b"vers");
types.extend_from_slice(&0u16.to_be_bytes());
types.extend_from_slice(&(list as u16).to_be_bytes());
types.extend_from_slice(b"sfnt");
types.extend_from_slice(&((n - 1) as u16).to_be_bytes());
types.extend_from_slice(&((list + 12) as u16).to_be_bytes());
let reference = |id: i16, offset: u32| {
let mut r = Vec::new();
r.extend_from_slice(&id.to_be_bytes());
r.extend_from_slice(&0xFFFFu16.to_be_bytes());
r.extend_from_slice(&offset.to_be_bytes());
r.extend_from_slice(&[0; 4]);
r
};
types.extend(reference(1, offsets[n]));
for (i, o) in offsets[..n].iter().enumerate() {
types.extend(reference(5000 + i as i16, *o));
}
// The map: a copy of the header, a handle, attributes, then the offsets of the lists.
let mut map = vec![0; 24];
map.extend_from_slice(&28u16.to_be_bytes());
map.extend_from_slice(&((28 + types.len()) as u16).to_be_bytes());
map.extend(types);
let mut fork = Vec::new();
for v in [256u32, 256 + data.len() as u32, data.len() as u32, map.len() as u32] {
fork.extend_from_slice(&v.to_be_bytes());
}
fork.resize(256, 0);
fork.extend(data);
fork.extend(map);
fork
}
+39
View File
@@ -263,3 +263,42 @@ fn fonts_the_platform_lists_outside_the_font_folders_are_cataloged() {
assert_eq!(db.styles("Service Sans3"), ["Regular"]);
assert!(!FontDb::with_font_dirs(vec![file.parent().unwrap().to_path_buf()]).has_family("Service Sans3"));
}
#[test]
fn a_resource_fork_gives_up_its_font_files() {
let (regular, bold) = (renamed("SourceSans3-Regular.ttf"), renamed("SourceSans3-Bold.ttf"));
let fork = crate::suitcase::fork_of(&[regular.clone(), bold.clone()]);
assert_eq!(crate::suitcase::sfnt_resources(&fork), [regular.as_slice(), bold.as_slice()]);
// Damage keeps what was read before it, and never panics.
for cut in [0, 3, 16, 300, fork.len() - 1] {
let _ = crate::suitcase::sfnt_resources(&fork[..cut]);
}
assert!(crate::suitcase::sfnt_resources(&[0xFF; 600]).is_empty());
// A fork can't make the scan read more than so many fonts.
assert_eq!(crate::suitcase::sfnt_resources(&crate::suitcase::fork_of(&vec![b"font".to_vec(); 300])).len(), 256);
}
/// Office's fonts: a file with nothing in it but a resource fork holding the fonts, named
/// without an extension. Only macOS keeps a resource fork in a file.
#[cfg(target_os = "macos")]
#[test]
fn the_scan_reads_suitcase_fonts() {
let dir = font_dir("suitcase");
std::fs::remove_file(dir.join("Sysfont-Regular.ttf")).unwrap();
std::fs::remove_file(dir.join("Sub/Sysfont-Bold.TTF")).unwrap();
let suitcase = dir.join("Sysfont Sans3");
std::fs::write(&suitcase, b"").unwrap();
std::fs::write(
suitcase.join("..namedfork/rsrc"),
crate::suitcase::fork_of(&[renamed("SourceSans3-Regular.ttf"), renamed("SourceSans3-Bold.ttf")]),
)
.unwrap();
// A file with nothing in either fork is no font.
std::fs::write(dir.join("empty"), b"").unwrap();
let db = FontDb::with_font_dirs(vec![dir.clone()]);
assert_eq!(db.styles(FAMILY), ["Regular", "Bold"]);
let bold = db.face(FAMILY, "Bold").unwrap();
assert_eq!((bold.family.as_str(), bold.style.as_str()), (FAMILY, "Bold"));
assert_eq!(bold.path(), Some(suitcase.as_path()));
assert_eq!(db.find_family("SysfontSans3").as_deref(), Some(FAMILY), "by a PostScript-style name, as an EPS names it");
}
+46 -5
View File
@@ -73,9 +73,10 @@ pub struct SelectionTool {
guide: GuideEdit,
}
/// Snapping for the selection moved as a whole, its targets gathered when the move begins: Smart
/// Guides line its bounds up with the other art; with them off, View → Snap to Point lands the
/// point it was grabbed by on an anchor or a ruler guide; Snap to Pixel puts its top-left on whole
/// Snapping for the selection moved as a whole, its targets gathered when the move begins: View →
/// Snap to Grid lands its bounds on the grid (#740), taking over from the rest; otherwise Smart
/// Guides line its bounds up with the other art, and with them off, View → Snap to Point lands the
/// point it was grabbed by on an anchor or a ruler guide. Snap to Pixel puts its top-left on whole
/// pixels.
pub(crate) struct MoveSnap {
bounds: Option<Rect>,
@@ -87,14 +88,19 @@ impl MoveSnap {
pub(crate) fn new(cx: &ToolContext) -> Self {
Self {
bounds: selection_bounds(cx),
targets: cx.smart_guides.then(|| Targets::for_move(cx)),
points: Targets::snap_to_point(cx, &cx.selection.objects),
targets: (cx.smart_guides && !cx.snap_to_grid).then(|| Targets::for_move(cx)),
points: if cx.snap_to_grid { None } else { Targets::snap_to_point(cx, &cx.selection.objects) },
}
}
/// The move by `d` of the selection grabbed at `start`, snapped, and its guides.
pub(crate) fn snap(&self, cx: &ToolContext, start: Point, mut d: Vec2) -> (Vec2, Vec<Overlay>) {
let mut guides = vec![];
if cx.snap_to_grid
&& let Some(b) = self.bounds
{
d += grid_pull(b + d, cx.grid_step());
}
if let Some(t) = &self.points {
let (q, ov) = t.snap_point(start + d, cx.tol(cx.snap_tolerance));
d = q - start;
@@ -115,6 +121,15 @@ impl MoveSnap {
}
}
/// The shift that lands `r` on the grid (a gridline every `step`), on each axis by whichever of its
/// two edges and its centre is nearest a gridline, however far that is.
fn grid_pull(r: Rect, step: f64) -> Vec2 {
let pull = |vs: [f64; 3]| {
vs.iter().map(|v| vectorcraft_geom::snap::snap_to_grid(*v, step, 0.0) - v).min_by(|a, b| a.abs().total_cmp(&b.abs())).unwrap_or(0.0)
};
Vec2::new(pull([r.x0, r.center().x, r.x1]), pull([r.y0, r.center().y, r.y1]))
}
pub fn matrix_json(a: Affine) -> Value {
let c = a.as_coeffs();
json!([c[0], c[1], c[2], c[3], c[4], c[5]])
@@ -517,6 +532,32 @@ mod tests {
assert_eq!(a, vec![Action::Commit]);
}
/// #740: with Snap to Grid on, a moved object lands on the grid (every 9 pt by default) by
/// whichever of its edges or its centre is nearest a gridline, on each axis.
#[test]
fn a_move_lands_the_nearest_edge_or_centre_on_the_grid() {
let (d, id) = doc_with_rect();
let mut s = Selection::default();
s.add(id);
let p = paint();
let cx = ToolContext { snap_to_grid: true, smart_guides: true, ..cx(&d, &s, &p) };
let moved = |to: (f64, f64)| {
let mut t = SelectionTool::default();
t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0));
let a = t.pointer(&cx, &ev(PointerKind::Drag, to.0, to.1));
match a.get(1) {
Some(Action::Preview(_, v)) => (v["matrix"][4].as_f64().unwrap(), v["matrix"][5].as_f64().unwrap()),
other => panic!("{other:?}"),
}
};
// Bounds 100–200 moved by (13, 4): the centres (163, 154) are nearest gridlines (162, 153).
let (x, y) = moved((163.0, 154.0));
assert!((x - 12.0).abs() < 1e-9 && (y - 3.0).abs() < 1e-9, "{x} {y}");
// Moved by (7, 0): the right edge is on a gridline (207) and the top goes up to one (99).
let (x, y) = moved((157.0, 150.0));
assert!((x - 7.0).abs() < 1e-9 && (y + 1.0).abs() < 1e-9, "{x} {y}");
}
#[test]
fn shift_drag_on_a_selected_object_moves_it_constrained() {
let (d, id) = doc_with_rect();
+4 -2
View File
@@ -330,7 +330,8 @@ mod tests {
#[test]
fn save_a_copy_and_template_suggest_their_names() {
let (mut app, written, picks) = desktop("copy.vectorcraft");
app.session.prefs.templates_folder = "/templates".into();
let folder = vectorcraft_testkit::temp_dir("save-template-dialog");
app.session.prefs.templates_folder = folder.to_string_lossy().into_owned();
app.run("file.saveCopy", json!({})).unwrap();
assert_eq!(picks.borrow()[0].name, "Untitled-1 copy.vectorcraft");
assert!(app.ui.dialog.is_none(), "the native format has no options");
@@ -338,7 +339,8 @@ mod tests {
assert!(app.session.active().unwrap().path.is_none() && app.session.active().unwrap().is_dirty());
app.run("file.saveAsTemplate", json!({})).unwrap();
let pick = &picks.borrow()[1];
assert_eq!((pick.name.as_str(), pick.folder.as_deref()), ("Untitled-1 template.vctemplate", Some("/templates")));
assert_eq!(pick.name, "Untitled-1 template.vctemplate");
assert_eq!(pick.folder.as_deref().map(std::path::Path::new), Some(folder.as_path()));
assert_eq!(pick.filters, [("VectorCraft Template", &["vctemplate"][..])]);
assert_eq!(written.borrow()[1].0, "copy.vectorcraft", "the picked name is kept");
}
+154
View File
@@ -0,0 +1,154 @@
# Affinity 3 `.af`: source audit and validation boundaries
Audit date: 2026-10-09. This audit focuses on the unified `.af` document format,
not on increasing the number of legacy `.afdesign`, `.afphoto` or `.afpub` files.
The source expansion adds 28 original Affinity 3.2.3 saves and one derived
regression input to the four existing current-version `.af` illustrations.
Passing a reader test, retaining editable objects and matching a saved picture
are separate claims. None demonstrates compatibility with every Affinity feature.
## Clean-room evidence
The inputs for this work are public documentation, fixture licensing/provenance
notices and public document data. No Affinity executable, installation resources,
private API or third-party Affinity parser implementation was read. No Adobe
application was run or used as an oracle. Existing project restrictions still
apply to Adobe-authored assets and software.
The additional Patchy files below were fetched as individual document blobs.
Only its fixture directory listing, MIT license and third-party notice were read;
its importer, tests and authoring scripts were not consulted. These files provide
independently authored input data, not an implementation to translate.
The upstream notice attributes the ordinary `tiny-*.af` files to the Patchy team,
using licensed Affinity 3.2.3 through its public JavaScript SDK, and describes the
content as original, with no third-party content. It separately identifies
`tiny-lazy-placed.af` as a deterministic byte-level derivation. That file is a
regression/hostile-input case, not evidence that Affinity saved the derived layout.
See the pinned [provenance notice](https://github.com/SethRobinson/Patchy/blob/de84eab550758b30fa062e479f5778cce7693b73/NOTICE-THIRD-PARTY.md)
and [MIT license](https://github.com/SethRobinson/Patchy/blob/de84eab550758b30fa062e479f5778cce7693b73/LICENSE).
An audit using this project's parser found absolute personal-path metadata in all
33 current `.af` files, including the four existing CC0 illustrations. These
values were not copied into reports or source, and are not imported into the
engine document. Do not claim that the downloadable inputs contain no personal
paths. Inspection of archive-entry names/signatures and stream byte/blob values
found no TTF, OTF, TTC, WOFF or WOFF2 binaries; this signature check is not a
complete font-file validation.
## Public-source decisions
| Source | Current `.af` evidence | Features or equivalent files advertised | Rights and decision |
|---|---|---|---|
| [samuel-etver/vector-art](https://github.com/samuel-etver/vector-art/tree/255f8add3c8f0740196e22bd59502b811b532f0b/simple) | Four `.af` files already pinned by this project | Mexican character illustrations and playing cards; real vector art | CC0. Retain the current-version vector examples; no paired PSD identified. |
| [SethRobinson/Patchy fixture data](https://github.com/SethRobinson/Patchy/tree/de84eab550758b30fa062e479f5778cce7693b73/test-fixtures/af) | 29 `.af` blobs at commit `de84eab550758b30fa062e479f5778cce7693b73`; author states Affinity 3.2.3, with one separately identified derived file | Artboards, text, placed images, groups, vectors, masks, shapes, transforms, raster depth, color spaces and unsupported-feature examples | MIT, copyright Seth A. Robinson, 2026; original fixture content per notice. Fetch pinned data only, SHA-256 verify, preserve attribution. No paired PSD identified in this directory. |
Other public `.af` sources found while searching (commercial templates and mockups, and
pages offering files without a redistribution licence) were left out: a downloadable
template, or a free font it uses, isn't permission to redistribute it. Files published
before the unified format aren't current-version evidence. This is a sample of
discovered sources, not a claim that every public file was found.
No openly redistributable, inspected Affinity 3 `.af`/PSD pair covering complex
typography and artboards was located in this audit.
## Additional current-version fixture inventory
The corpus names below map to upstream `test-fixtures/af/tiny-<suffix>.af`.
For example, `patchy-artboards.af` comes from
[tiny-artboards.af](https://github.com/SethRobinson/Patchy/blob/de84eab550758b30fa062e479f5778cce7693b73/test-fixtures/af/tiny-artboards.af).
The source pin, filenames and SHA-256 digest manifest are the reproducibility
boundary; never fetch a floating branch for CI.
| Area | Corpus files | What the file selection can test |
|---|---|---|
| Artboards and units | `patchy-artboards.af`, `patchy-dpi300.af` | Board identity, rectangles, content association, clipping and conversion from document resolution to points |
| Editable typography | `patchy-text-artistic.af`, `patchy-text-frame.af`, `patchy-text-runs.af`, `patchy-text-rotated.af` | Native text retention, per-run styling, frame geometry and composed transforms |
| Paragraph/character differences | `patchy-text-indent.af`, `patchy-text-para-spacing.af`, `patchy-text-caps.af` | Detect unsupported text layout and case features; a passing parse is insufficient evidence of layout fidelity |
| Curves, shapes and masks | `patchy-vector.af`, `patchy-vector-mask.af`, `patchy-shapes.af`, `patchy-shapes-2.af` | Editable paths, mask attachment and known/fallback parametric shapes |
| Structure and transforms | `patchy-group.af`, `patchy-transform.af` | Nested groups, names, stacking and transform composition |
| Images and pixels | `patchy-embedded-jpeg.af`, `patchy-rgba8.af`, `patchy-rgba16.af`, `patchy-cmyk.af`, `patchy-lab.af` | Original image retention, tiled pixels, supported depths and explicit color-space limitations |
| Effects/adjustments/blends | `patchy-fx.af`, `patchy-fx-blur.af`, `patchy-fx-gradient.af`, `patchy-adjust-curves.af`, `patchy-adjust-hsl.af`, `patchy-live-filter.af`, `patchy-blend-affinity.af` | Warnings and safe fallbacks for unsupported visual operations; not automatic feature support |
| Saved revisions and derived input | `patchy-incremental-chain.af`, `patchy-lazy-placed.af` | Revision selection and bounded handling of a derived placement case |
These are focused feature probes. They complement the larger vector illustrations
but do not replace a large production document with many interacting features.
Fixture filenames alone do not prove the imported model preserved those features;
assert native model structure and imported engine behavior independently.
## PSD compatibility oracle design
Affinity's public [PSD-import guide](https://www.affinity.studio/blog/how-to-open-a-psd-file)
documents import settings that change the result: smart objects can be rasterized
or retained, and text can be imported as text or bitmap. It also describes artboard
mapping and profile conversion. Therefore, a same-looking PSD is not inherently
an equivalent editable document, and an export can lose features before our
reader sees it.
For a future paired oracle, record the original author, license for all embedded
art, Affinity version, platform, import settings, profile, resolution, fonts,
export preset and each expected semantic loss. Keep an original `.af`, a native
Affinity-rendered PNG per board at full resolution, an Affinity-exported PSD and
a human-readable object/layout inventory. Do not run Photoshop or another Adobe
application to generate or validate the comparison. A PSD written by VectorCraft
after import tests our writer/round trip; it is not an independent Affinity oracle.
Compare board dimensions and names, object counts/ordering/visibility, text and
run boundaries, placed-image transforms, mask geometry and transparency, then
render each board with the documented background. Document which PSD objects
are rasterized, approximated or absent. A flat full-document thumbnail can hide
lost boards, editable text or misplaced children. Require native-import success
where native support is claimed; preview fallback must never satisfy that test.
## Typography and complex-document coverage still required
Affinity publicly documents [variable fonts, OpenType features, path text,
artistic/frame text and precision spacing](https://www.affinity.studio/typography-software).
The discovered real-file set does not establish faithful support for all of them.
| Required independent fixture | Evidence needed before claiming support |
|---|---|
| Variable fonts and named instances | Font family plus axes/instance settings preserved; exact pinned font version; non-default weight/width/optical-size rendering |
| OpenType feature combinations | Ligatures, discretionary ligatures, stylistic sets, alternate glyphs, small caps, numeric forms and kerning controls; compare both text content and glyph output |
| Mixed scripts and direction | Arabic joining/RTL, Hebrew, Devanagari conjuncts, Thai combining marks, CJK line breaks, bidi mixed numbers, combining accents and supplementary-plane characters |
| Mixed runs and font fallback | Multiple families, sizes, colors and baseline shifts within one paragraph; missing font and missing glyph outcomes reported without silently changing text |
| Advanced frames and flow | Columns, insets, linked frames, overflow, tabs/indents, paragraph spacing, justified text, wrapping around objects and text on a transformed path |
| Font representations | TrueType, CFF/OpenType, collection faces, variable outlines and color glyph formats; avoid treating family-name retention as outline support |
| Large interacting document | Multiple unequal/overlapping boards, off-board artwork, nested clips/masks, rotations/skews, repeated placed/embedded documents, symbols, images and text at non-72 dpi |
| Pixel/vector interaction | Raster depths/color spaces, blend/opacity combinations, effects, live adjustments, brushes and pressure profiles with explicit warnings for unsupported cases |
Acquire these as original contributor documents with explicit redistribution
rights, or add independently published originals with complete provenance.
Fonts belong in [storytold/craft-fonts](https://github.com/storytold/craft-fonts),
not this repository; a fixture's font names do not grant redistribution rights
to font binaries. Synthetic containers remain useful for parser limits and
precise regression tests, but cannot establish real Affinity saves or rendering
semantics for an unobserved feature.
Tests must distinguish supported behavior, approximated behavior with warnings,
rejected input and unvalidated behavior. Keep the unmet rows above visible when
reporting results: there is no evidence for universal `.af` compatibility.
## Checks added in this change
The pinned corpus contains 51 documents, including 33 current `.af` files with
34 artboards. Native model signatures freeze retained geometry, paints, masks,
images, text runs, fonts, bounds and warnings. Every current board is saved and
reloaded in `.vectorcraft`, then exported through SVG, PDF and PSD. SVG/PDF
dimensions and reimport are checked; a test-only decoder written from the public
PSD specification compares merged pixels and alpha with VectorCraft's PNG output.
These export checks establish internal consistency, not independent Affinity fidelity.
A headless VectorCraft MCP session also opens the real two-board `.af`, saves
and reopens `.vectorcraft` with the complete saved model unchanged, exports both
boards as SVG/PSD, then reopens and exports the two-page PDF again with embedded
editing data disabled, exercising the PDF reader rather than native restoration. The normal
save command adds creation/modification metadata for the new native file.
Saved-thumbnail comparisons cover 36 board crops across 31 documents. Both boards
in the Affinity 3 artboard probe match exactly; the embedded JPEG probe has a mean
difference of 0.50/255. Per-file ceilings preserve measured regression limits;
unsupported text layout and effects are tested structurally and through warnings.
No check reopens VectorCraft's output in Affinity yet, and no `.af` export is
claimed by this change: the independent reopen procedure above needs Affinity on a
supported desktop.
+3 -1
View File
@@ -194,7 +194,9 @@ parameters. Shipped code therefore never panics. Anything that can fail returns
### Enforced by lints
`Cargo.toml` denies these clippy lints for the whole workspace, and `cargo xtask ci` runs clippy with
`-D warnings`:
`-D warnings`. The gate targets the current stable Rust (`rust-version` in `Cargo.toml` is only the
minimum that builds the app, and no toolchain is pinned): when a new stable adds lints, they are fixed
in the code:
| Banned in shipped code | Use instead |
|---|---|
+7
View File
@@ -112,6 +112,13 @@ to the target triple, so host build scripts aren't affected.
- `vectorcraft.wxs` (WiX) is a per-machine install into Program Files with a Start Menu shortcut
and an App Paths entry (Win+R `vectorcraft`). Same-version upgrades are allowed, so release
candidates replace each other.
- Double-clicking the MSI opens a setup wizard (WixUI_InstallDir without the licence page):
Welcome, install folder (remembered for upgrades in `HKLM\Software\VectorCraft\InstallDir`),
Ready, a progress page, and a Finish page that says where to find the app, with a ticked "Launch
VectorCraft" box. The pages name the full version (`vectorcraft.en-us.wxl`). `package.ps1` draws
the wizard's banner and side bitmaps from the app icon, so no WiX stock art ships. `msiexec /qn`
still installs silently. Building needs the WiX UI and Util extensions:
`wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2`.
- The ARM64 build is cross-compiled on the x64 runner. `.github/workflows/windows-arm64.yml`
installs that MSI on a Windows 11 ARM64 runner, checks that both installed programs are ARM64,
runs `vectorcraft-cli --version` natively and uninstalls.
+42 -1
View File
@@ -13,6 +13,7 @@
Needs: Rust (MSVC toolchain + the target), the Windows SDK (rc.exe, signtool.exe),
and WiX v5: dotnet tool install --global wix --version 5.0.2
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
.EXAMPLE
pwsh packaging/windows/package.ps1 -Arch x64
@@ -91,10 +92,50 @@ Copy-Item (Join-Path $Bin 'vectorcraft.exe'), (Join-Path $Bin 'vectorcraft-cli.e
& (Join-Path $PSScriptRoot 'sign.ps1') (Join-Path $Stage 'vectorcraft.exe') (Join-Path $Stage 'vectorcraft-cli.exe')
# ---- MSI ---------------------------------------------------------------------------------------
# The setup wizard's art, drawn here from the app icon so the MSI shows VectorCraft rather than
# WiX's stock bitmaps: a 493x58 banner across the top of the inner pages (the page title is drawn
# over its left side, so that stays white) and a 493x312 backdrop for the Welcome and Finish pages
# (their text sits on the right; the left 164 px are ours). Both must be 24-bit BMPs.
function New-InstallerArt([string] $IconPng, [string] $BannerOut, [string] $DialogOut) {
Add-Type -AssemblyName System.Drawing
$paper = [System.Drawing.ColorTranslator]::FromHtml('#efe9dc')
$accent = [System.Drawing.ColorTranslator]::FromHtml('#e8573f')
$icon = [System.Drawing.Image]::FromFile($IconPng)
try {
foreach ($spec in @(@($BannerOut, 493, 58), @($DialogOut, 493, 312))) {
$bmp = New-Object System.Drawing.Bitmap $spec[1], $spec[2], ([System.Drawing.Imaging.PixelFormat]::Format24bppRgb)
$g = [System.Drawing.Graphics]::FromImage($bmp)
try {
$g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
$g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
$g.Clear([System.Drawing.Color]::White)
if ($spec[2] -eq 58) {
$g.DrawImage($icon, 493 - 46 - 8, 6, 46, 46)
} else {
$panel = New-Object System.Drawing.SolidBrush $paper
$stripe = New-Object System.Drawing.SolidBrush $accent
$g.FillRectangle($panel, 0, 0, 164, 312)
$g.FillRectangle($stripe, 160, 0, 4, 312)
$g.DrawImage($icon, 18, 64, 124, 124)
$panel.Dispose(); $stripe.Dispose()
}
} finally { $g.Dispose() }
$bmp.Save($spec[0], [System.Drawing.Imaging.ImageFormat]::Bmp)
$bmp.Dispose()
}
} finally { $icon.Dispose() }
}
$Banner = Join-Path $Stage 'installer-banner.bmp'
$Dialog = Join-Path $Stage 'installer-dialog.bmp'
New-InstallerArt (Join-Path $Root 'assets\app-icon\hicolor\256x256\apps\ai.storyteller.vectorcraft.png') $Banner $Dialog
$Msi = Join-Path $Dist "vectorcraft-$Version-windows-$Arch.msi"
Invoke-Native 'wix build' {
wix build (Join-Path $PSScriptRoot 'vectorcraft.wxs') -arch $Arch `
-d "Version=$MsiVersion" -d "BinDir=$Stage" -d "IconPath=$(Join-Path $Root 'assets\app-icon\vectorcraft.ico')" `
-ext WixToolset.UI.wixext -ext WixToolset.Util.wixext `
-culture en-US -loc (Join-Path $PSScriptRoot 'vectorcraft.en-us.wxl') `
-d "Version=$MsiVersion" -d "DisplayVersion=$Version" -d "BinDir=$Stage" -d "IconPath=$(Join-Path $Root 'assets\app-icon\vectorcraft.ico')" `
-d "BannerBmp=$Banner" -d "DialogBmp=$Dialog" `
-o $Msi
}
# wix writes its debug symbols (.wixpdb) next to the MSI; keep them out of the release assets.
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Overrides for the setup wizard's WixUI strings, so its pages name the version being installed
([VectorcraftVersion] is the full version, pre-release tag included, set in vectorcraft.wxs).
-->
<WixLocalization Culture="en-US" xmlns="http://wixtoolset.org/schemas/v4/wxl">
<String Id="WelcomeDlgTitle" Value="{\WixUI_Font_Bigger}Welcome to the [ProductName] [VectorcraftVersion] Setup Wizard" />
<String Id="WelcomeDlgDescription" Value="The Setup Wizard will install [ProductName] version [VectorcraftVersion] on your computer. Click Next to continue or Cancel to exit the Setup Wizard." />
<String Id="VerifyReadyDlgInstallTitle" Value="{\WixUI_Font_Title}Ready to install [ProductName] [VectorcraftVersion]" />
<String Id="ExitDialogTitle" Value="{\WixUI_Font_Bigger}Completed the [ProductName] [VectorcraftVersion] Setup Wizard" />
</WixLocalization>
+42 -5
View File
@@ -1,13 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
VectorCraft MSI (WiX Toolset v5: `dotnet tool install -g wix`). Built by package.ps1:
VectorCraft MSI (WiX Toolset v5: `dotnet tool install -g wix`, plus the UI and Util extensions:
`wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2`). Built by package.ps1:
wix build vectorcraft.wxs -arch x64 -d Version=1.2.3 -d BinDir=... -d IconPath=... -o out.msi
wix build vectorcraft.wxs -arch x64 -ext WixToolset.UI.wixext -ext WixToolset.Util.wixext
-culture en-US -loc vectorcraft.en-us.wxl -d Version=1.2.3 -d DisplayVersion=1.2.3-rc.1
-d BinDir=... -d IconPath=... -d BannerBmp=... -d DialogBmp=... -o out.msi
Per-machine install into Program Files, a Start Menu shortcut and an App Paths entry (so
`vectorcraft` starts from the Run dialog), plus vectorcraft-cli.
Per-machine install with a setup wizard that names the version: Welcome, install folder (Program Files\VectorCraft by
default, remembered for upgrades), Ready, a progress page, and a Finish page with a ticked
"Launch VectorCraft" box. Installs a Start Menu shortcut and an App Paths entry (so `vectorcraft`
starts from the Run dialog), plus vectorcraft-cli. `msiexec /qn` still installs silently.
-->
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"
xmlns:ui="http://wixtoolset.org/schemas/v4/wxs/ui">
<Package
Name="VectorCraft"
Manufacturer="Learning Machines LLC"
@@ -34,9 +40,39 @@
</StandardDirectory>
<StandardDirectory Id="ProgramMenuFolder" />
<!-- An upgrade installs where the user put the previous version (written by VectorcraftApp). -->
<Property Id="INSTALLFOLDER">
<RegistrySearch Id="PreviousInstallFolder" Root="HKLM" Key="Software\VectorCraft" Name="InstallDir" Type="raw" />
</Property>
<Feature Id="Main" Title="VectorCraft" Level="1">
<ComponentGroupRef Id="VectorcraftFiles" />
</Feature>
<!-- The setup wizard: WixUI_InstallDir without its licence page (the code is MIT OR
Apache-2.0; there is nothing to accept). A later NewDialog on the same button wins. -->
<ui:WixUI Id="WixUI_InstallDir" InstallDirectory="INSTALLFOLDER" />
<UI>
<Publish Dialog="WelcomeDlg" Control="Next" Event="NewDialog" Value="InstallDirDlg" Order="2" Condition="NOT Installed" />
<Publish Dialog="InstallDirDlg" Control="Back" Event="NewDialog" Value="WelcomeDlg" Order="2" />
<Publish Dialog="ExitDialog" Control="Finish" Event="DoAction" Value="LaunchVectorcraft"
Condition="WIXUI_EXITDIALOGOPTIONALCHECKBOX = 1 AND NOT Installed" />
</UI>
<!-- Our own art, generated by package.ps1 from the app icon (not WiX's stock bitmaps). -->
<WixVariable Id="WixUIBannerBmp" Value="$(var.BannerBmp)" />
<WixVariable Id="WixUIDialogBmp" Value="$(var.DialogBmp)" />
<!-- The full version (Version is numeric, so it drops a pre-release tag), shown on the wizard's
pages through vectorcraft.en-us.wxl. -->
<Property Id="VectorcraftVersion" Value="$(var.DisplayVersion)" />
<!-- Finish page: say where to find the app, and offer to start it (ticked by default). -->
<Property Id="WIXUI_EXITDIALOGOPTIONALTEXT" Value="VectorCraft $(var.DisplayVersion) is installed. You can start it any time from the Start menu." />
<Property Id="WIXUI_EXITDIALOGOPTIONALCHECKBOXTEXT" Value="Launch VectorCraft" />
<Property Id="WIXUI_EXITDIALOGOPTIONALCHECKBOX" Value="1" />
<Property Id="WixShellExecTarget" Value="[#VectorcraftExe]" />
<!-- Runs in the wizard's own (unelevated) process, so the app starts as the user, not as admin. -->
<CustomAction Id="LaunchVectorcraft" BinaryRef="Wix4UtilCA_$(sys.BUILDARCHSHORT)" DllEntry="WixShellExec" Impersonate="yes" Return="ignore" />
</Package>
<Fragment>
@@ -49,6 +85,7 @@
</File>
<RegistryValue Root="HKLM" Key="Software\Microsoft\Windows\CurrentVersion\App Paths\vectorcraft.exe" Value="[#VectorcraftExe]" Type="string" />
<RegistryValue Root="HKLM" Key="Software\VectorCraft" Name="InstallDir" Value="[INSTALLFOLDER]" Type="string" />
</Component>
<Component Id="VectorcraftCli">
+31
View File
@@ -20,3 +20,34 @@ fbb196e918e302c16f8de9f2e6dffaef70ab3c317b32a9f44607fd4b2ead0206 jac21-SimpleLo
9d874245936483de3ec04cca48b383028d33666801dff1814246a44e695e29b8 jac21-SimpleLogoBanner.afdesign
e1b121d7c3d02356baa5dea512f5aa1fe77249c28d7b227e29cab2bc67ec323a eviltwo-AssetStore.aftemplate
ec1072cb03fcd0ed7ac1dfc7e087f4920df545fe9c84a386a7dac2fd33fbbe1f leakcanary-vector_icon.afdesign
9bea4e2ceddc2438943c3b26505299389d07c64bbe2821be01eba9e69a06ce1e patchy-adjust-curves.af
3141df41efeab7a250782e1418d96d4faf3b15107763df8002714aa8a8edd259 patchy-adjust-hsl.af
0361284661e1376b91675cb5f7705b1d560711c9f86aa53e255ee5365e023680 patchy-artboards.af
7239c42b6f9fb25b6ad9c12397afb4e0c1361a505e8126855d3a8cb45b192b87 patchy-blend-affinity.af
4d1fd761dd1508a31d37c4f4acbe01ca0d336ef3cb9bfdcddfc40ad4102f9d74 patchy-cmyk.af
75975eea4c325b90beac5015f6c2731156e90a5ec0f4c0a22aa6324e5dcb4ffc patchy-dpi300.af
bd15ca80e322eb38460176e1b9b187247c3b9e455e55624f71ae5db1122a1eed patchy-embedded-jpeg.af
62c9a5c4cff475a1a47ad6582d24b4fcb56ac84e94641b7ca44c9f8b05053bf1 patchy-fx-blur.af
d515c84a702aee19359740e61f4ff16b02e363533f24287be5e1ba4b3539ef54 patchy-fx-gradient.af
eeb8f0988a7effe2088ead4015916bed13875d9aaa88a3bf477127214831e820 patchy-fx.af
3da524362f70f316c3be4beab2022e2ae7275bbd153cc7204bc328a7dc8a538a patchy-group.af
25ee650339e7951a4db76932e889e0558134bd6b55be7c5b5c7d54b601c053bf patchy-incremental-chain.af
f83c1bd0108315c95f55ddbfb84b7188e73c301dbeb0e3a209ddf991075737b0 patchy-lab.af
da724875124ca2dea2ab69671198c9e0af87f8e325d634c8f6d4256210b00dcc patchy-lazy-placed.af
59da2bb7d58a356e09ca1819020af85bb05217b1b24ec55df9f9f2ff5235c68a patchy-live-filter.af
9e505e5dc5f0854d2a13ae30699b000b32f40b80d80afd570f00a33952ca895e patchy-rgba16.af
9f2a838484399dfc4da4af893facf9d0cd33ed40173aa88264fe8f3756d81a42 patchy-rgba8.af
5dab9c128aa791558ccb8e995e6bf89cc1c50bfcd907dfdce9c764163503e17a patchy-shapes-2.af
dba88c1c2e1c1cca947af6ebc82e32457301b228c8f6a9483edbae7c4ef32653 patchy-shapes.af
f03afbf8965a137439f2fb5d228e2c3349ca81d26e4e7e426b2fcc191348a7f4 patchy-text-artistic.af
66b4fadba91cdf974c1ddef021ce073ecc09b5b25d8c273d400d6c71265b7b75 patchy-text-caps.af
0cf93a1105878ad237e7f3d724be8e85619efd91d2c7fc6ae63b121e0930fc72 patchy-text-frame.af
dfb6ca947e0d33091a7d6d9466f379cc22e3e8847ae7722d8e93bd89a0e8b813 patchy-text-indent.af
56c5d030b9956151110e57fe83aebc47d141efce4df424f6ff5643312c4a7549 patchy-text-para-spacing.af
a5ccb62ee8d7e36d1a19a037aba989a5ba0232767e9f3305f7d9f7d4603291dd patchy-text-rotated.af
07c54ed90e49c4d791964228bee638fbff95a7b06dc3eccbedb3016f9fcef05e patchy-text-runs.af
87e1dee23769932b7e414116233063a8d68726cbffdebb975b3cd0ef447c602a patchy-transform.af
fa5dd80f44ae0712a9888c62953fd442a28119034d343db93b7892d5d2a61328 patchy-vector-mask.af
8ab1995ecbd9306ebcb7a4d2cd81701f34401e67f08b242f14dc3e3d04c94803 patchy-vector.af
bbc50c8c376e0e5980939be7df6769feed1a30289c7efc6391b204dfb15de88d patchy-LICENSE.txt
150e423be0c5eb22f252a38c2de68ab3ccec7e20bfba166f7ddd622ac243d8f4 patchy-NOTICE.txt
+44 -3
View File
@@ -3,8 +3,9 @@
//! They land in `corpus/affinity/` (gitignored, never committed). `vectorcraft-affinity`'s
//! `real_files` test and the engine's `affinity_corpus` test use them when present.
//!
//! Only permissively licensed files (CC0, MIT, Apache-2.0) whose metadata holds no personal paths
//! are listed. Moving a pin: change the commit, re-run, review the manifest diff.
//! Only permissively licensed files (CC0, MIT, Apache-2.0) are listed. Fixture provenance and
//! exclusions are documented in docs/affinity-validation.md. Moving a pin: change the commit,
//! re-run, review the manifest diff. Metadata is never imported or published by the app.
use std::path::Path;
use std::process::Command;
@@ -15,6 +16,44 @@ use crate::{root, run, sha256};
type Source = (&'static str, &'static str, &'static str, &'static [(&'static str, &'static str)]);
const SOURCES: &[Source] = &[
(
"SethRobinson/Patchy",
"de84eab550758b30fa062e479f5778cce7693b73",
"MIT",
&[
("LICENSE", "patchy-LICENSE.txt"),
("NOTICE-THIRD-PARTY.md", "patchy-NOTICE.txt"),
("test-fixtures/af/tiny-adjust-curves.af", "patchy-adjust-curves.af"),
("test-fixtures/af/tiny-adjust-hsl.af", "patchy-adjust-hsl.af"),
("test-fixtures/af/tiny-artboards.af", "patchy-artboards.af"),
("test-fixtures/af/tiny-blend-affinity.af", "patchy-blend-affinity.af"),
("test-fixtures/af/tiny-cmyk.af", "patchy-cmyk.af"),
("test-fixtures/af/tiny-dpi300.af", "patchy-dpi300.af"),
("test-fixtures/af/tiny-embedded-jpeg.af", "patchy-embedded-jpeg.af"),
("test-fixtures/af/tiny-fx-blur.af", "patchy-fx-blur.af"),
("test-fixtures/af/tiny-fx-gradient.af", "patchy-fx-gradient.af"),
("test-fixtures/af/tiny-fx.af", "patchy-fx.af"),
("test-fixtures/af/tiny-group.af", "patchy-group.af"),
("test-fixtures/af/tiny-incremental-chain.af", "patchy-incremental-chain.af"),
("test-fixtures/af/tiny-lab.af", "patchy-lab.af"),
("test-fixtures/af/tiny-lazy-placed.af", "patchy-lazy-placed.af"),
("test-fixtures/af/tiny-live-filter.af", "patchy-live-filter.af"),
("test-fixtures/af/tiny-rgba16.af", "patchy-rgba16.af"),
("test-fixtures/af/tiny-rgba8.af", "patchy-rgba8.af"),
("test-fixtures/af/tiny-shapes-2.af", "patchy-shapes-2.af"),
("test-fixtures/af/tiny-shapes.af", "patchy-shapes.af"),
("test-fixtures/af/tiny-text-artistic.af", "patchy-text-artistic.af"),
("test-fixtures/af/tiny-text-caps.af", "patchy-text-caps.af"),
("test-fixtures/af/tiny-text-frame.af", "patchy-text-frame.af"),
("test-fixtures/af/tiny-text-indent.af", "patchy-text-indent.af"),
("test-fixtures/af/tiny-text-para-spacing.af", "patchy-text-para-spacing.af"),
("test-fixtures/af/tiny-text-rotated.af", "patchy-text-rotated.af"),
("test-fixtures/af/tiny-text-runs.af", "patchy-text-runs.af"),
("test-fixtures/af/tiny-transform.af", "patchy-transform.af"),
("test-fixtures/af/tiny-vector-mask.af", "patchy-vector-mask.af"),
("test-fixtures/af/tiny-vector.af", "patchy-vector.af"),
],
),
(
"samuel-etver/vector-art",
"255f8add3c8f0740196e22bd59502b811b532f0b",
@@ -97,7 +136,9 @@ pub fn fetch() -> Result<(), String> {
}
}
sources.push_str(&format!("| {name} | https://github.com/{repo}/blob/{commit}/{path} | {licence} |\n"));
count += 1;
if out.extension().is_some_and(|e| e != "txt") {
count += 1;
}
}
}
std::fs::write(dest.join("SOURCES.md"), sources).map_err(|e| format!("SOURCES.md: {e}"))?;
+3 -3
View File
@@ -19,10 +19,10 @@ pub fn hex(data: &[u8]) -> String {
msg.push(0);
}
msg.extend(bits.to_be_bytes());
for block in msg.chunks_exact(64) {
for block in msg.as_chunks::<64>().0.iter() {
let mut w = [0u32; 64];
for (i, word) in block.chunks_exact(4).enumerate() {
w[i] = u32::from_be_bytes([word[0], word[1], word[2], word[3]]);
for (i, word) in block.as_chunks::<4>().0.iter().enumerate() {
w[i] = u32::from_be_bytes(*word);
}
for i in 16..64 {
let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);