mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aef5790e01 | ||
|
|
f59be80671 | ||
|
|
b411a3d158 | ||
|
|
a27c912f8a | ||
|
|
5d74c5bb77 | ||
|
|
fa3d78a4b2 | ||
|
|
1056a42a56 | ||
|
|
d464f28ed3 | ||
|
|
83b51ab9c7 | ||
|
|
3e94f6ffd6 | ||
|
|
5acc269a41 | ||
|
|
c4e20023de | ||
|
|
e9bbf43d15 | ||
|
|
1e7f5084d8 | ||
|
|
843507549f | ||
|
|
b554330d06 | ||
|
|
eec150e276 | ||
|
|
2de7257736 | ||
|
|
256702bab0 | ||
|
|
498a2d1e6b | ||
|
|
4ab13dabfb | ||
|
|
f1157456a1 | ||
|
|
761b8fc080 | ||
|
|
5d41601da1 | ||
|
|
e80e47a295 | ||
|
|
67a62a7fe7 | ||
|
|
b5ae419734 | ||
|
|
2e55ce6d5b | ||
|
|
2b4d8f4074 | ||
|
|
297a01b6aa | ||
|
|
1c47480a09 | ||
|
|
3e782b22a9 | ||
|
|
3c7bdfe6a3 | ||
|
|
c13e4133e0 |
No files matched your search
Generated
+27
@@ -3460,6 +3460,15 @@ dependencies = [
|
||||
"unicode-script",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruzstd"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a252f5e20f038fe7b4ea53e073e65398d652c864cc162fc77c56c2f13717b888"
|
||||
dependencies = [
|
||||
"twox-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
@@ -4123,6 +4132,12 @@ dependencies = [
|
||||
"core_maths",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "twox-hash"
|
||||
version = "2.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "86a801b3cea342a06d468c8710662aa29e5e05e4f5c0d62f00bbb7f2ad7941c2"
|
||||
|
||||
[[package]]
|
||||
name = "type-map"
|
||||
version = "0.5.1"
|
||||
@@ -4320,6 +4335,16 @@ dependencies = [
|
||||
"winresource",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-affinity"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"proptest",
|
||||
"ruzstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-brush"
|
||||
version = "0.7.0"
|
||||
@@ -4414,6 +4439,7 @@ dependencies = [
|
||||
name = "vectorcraft-engine"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4422,6 +4448,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"vectorcraft-affinity",
|
||||
"vectorcraft-brush",
|
||||
"vectorcraft-cad",
|
||||
"vectorcraft-color",
|
||||
|
||||
@@ -33,6 +33,7 @@ vectorcraft-cad = { path = "crates/cad" }
|
||||
vectorcraft-eps = { path = "crates/eps" }
|
||||
vectorcraft-metafile = { path = "crates/metafile" }
|
||||
vectorcraft-plugins = { path = "crates/plugins" }
|
||||
vectorcraft-affinity = { path = "crates/affinity" }
|
||||
|
||||
# foundation
|
||||
kurbo = { version = "0.13", features = ["serde"] }
|
||||
@@ -42,6 +43,7 @@ serde = { version = "1", features = ["derive", "rc"] }
|
||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
log = "0.4"
|
||||
proptest = "1"
|
||||
crc32fast = "1.5"
|
||||
# tests: a TCP port bound without listening (connections to it are refused)
|
||||
socket2 = "0.6"
|
||||
# Sandboxed WebAssembly plug-ins: a pure-Rust interpreter (MIT OR Apache-2.0) that builds for wasm32
|
||||
|
||||
@@ -193,7 +193,13 @@ milestones, and honest time-to-parity estimates.
|
||||
**Where we are (2026-10-07):** roughly 69–75% of Illustrator's features exist and work, and about 40–55% of
|
||||
"a power user can't tell the difference". Everyday vector illustration is close to usable: drawing and path tools,
|
||||
Pathfinder and Shape Builder, paint, gradients, appearance and transparency, type with styles, threading and Hebrew/Arabic bidirectional layout, and
|
||||
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, EPS, DXF, EMF/WMF, raster formats and PSD, Print, Package). The interface
|
||||
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, EPS, DXF, EMF/WMF, raster formats and PSD, Print, Package).
|
||||
Affinity documents (`.af` from Affinity 3, `.afdesign`, `.afpub` and, by their content, `.afphoto` from Affinity 1 and 2) open
|
||||
and place natively: layers, groups, artboards and pages, curves and shapes, fills, gradients and strokes, clipping
|
||||
and masks, text and images, with what didn't come in (effects, adjustments, brushes, master pages…) listed in the
|
||||
import warning; a file whose native data can't be read opens as its embedded preview, saying why. VectorCraft
|
||||
doesn't write Affinity files. [Scope and limits](crates/affinity/README.md).
|
||||
The interface
|
||||
speaks English, Japanese, Traditional and Simplified Chinese, Spanish, French, Italian and Russian (and Czech and Brazilian Portuguese in the menus).
|
||||
The scores are
|
||||
self-assessed, so the [honest assessment](ROADMAP.md#honest-assessment-2026-10-05) explains how far to trust them.
|
||||
|
||||
+17
-11
@@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob
|
||||
|
||||
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
|
||||
|
||||
_Last updated: 2026-10-09 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG windoLine truncated
|
||||
_Last updated: 2026-10-09 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG windoLine truncated
|
||||
|
||||
## Where we are
|
||||
|
||||
@@ -35,10 +35,10 @@ since 2026-10-01.
|
||||
|---|---|---|
|
||||
| **Breadth:** menus, tools and panels exist | ~90% | 13 menu items still stubbed; every tool implemented except Touch Type; 51 panel modules |
|
||||
| **Depth:** each feature behaves like Illustrator | ~69–75% | Strong: paint, appearance and colour (M3), Pathfinder and booleans, selection, drawing, files (M4). Weak: advanced type (~45%; inline symbols in text are beyond parity and don't raise it), brushes and symbols (in progress), raster effects (~20%) |
|
||||
| **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~1 of ~56 filters), SVG Filters, CJK composition (vertical type with kinsoku, tate-chu-yoko and the font's vertical metrics; ruby, mojikumi and proportional vertical metrics open), Variables (data merge), scripting |
|
||||
| **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~4 of ~56 filters), SVG Filters, CJK composition (vertical type with kinsoku, tate-chu-yoko and the font's vertical metrics; ruby, mojikumi and proportional vertical metrics open), Variables (data merge), scripting |
|
||||
| **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first |
|
||||
| **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured |
|
||||
| **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai`, EPS, DXF, EMF/WMF, raster formats, PSD export, Place and Links, Package, Print, clipboard flavours. Native `.ai` private data is out of scope by design; DWG has no open spec |
|
||||
| **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai`, EPS, DXF, EMF/WMF, raster formats, PSD export, Place and Links, Package, Print, clipboard flavours. Affinity documents (Affinity 1–3) open natively as editable art, checked against Affinity's own thumbnails of 22 pinned public files; Affinity export is missing and effects, adjustments and brushes don't import. Affinity isn't an Illustrator feature, so this doesn't raise the parity score. Native `.ai` private data is out of scope by design; DWG has no open spec |
|
||||
| **Bundled content:** brush, symbol, style and swatch libraries | ~30% | Ours are original and generated in code, and far fewer than Illustrator ships; brush and symbol libraries are still missing |
|
||||
| **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine |
|
||||
| **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA |
|
||||
@@ -53,9 +53,12 @@ Ordered by how much each gap blocks someone from switching. Sizes are one-agent
|
||||
1. **Interaction fidelity:** go tool by tool and panel by panel against `plan/illustrator/05-tools.md`,
|
||||
`06-panels.md` and `09-shortcuts.md`, covering modifier keys, cursors, the Properties panel per context and
|
||||
isolation mode. The Shaper’s rough-square recognition and shape orientation steps are corrected; Shaper Groups now retain editable originals for region merges/punches and separate fill/stroke edits; their construction-mode widget and gesture refinements remain. 60–90 h.
|
||||
2. **Photoshop-style raster effects and the Effect Gallery:** about 55 filters (Artistic, Brush Strokes, Distort,
|
||||
Pixelate, Sketch, Stylize, Texture, Video) on the raster pipeline that drop shadows already use, applied at
|
||||
Document Raster Effects Settings resolution. Parallelizes well across agents. 28–42 h.
|
||||
2. **Photoshop-style raster effects and the Effect Gallery:** about 52 filters (Artistic, Brush Strokes, Distort,
|
||||
Pixelate, Sketch, Stylize, Texture, Video) and the Effect Gallery. The pixel-filter pipeline is in place
|
||||
(`vectorcraft_effects::pixel`: the object rendered offscreen, filtered in stack order with Gaussian Blur and
|
||||
Feather, cached while panning, images in PDF/SVG/Expand Appearance) with Blur › Radial Blur and Smart Blur
|
||||
(Normal mode only) and Sharpen › Unsharp Mask; each new filter is now one function, a catalogue row, menu and
|
||||
translations. Parallelizes well across agents. 26–40 h.
|
||||
3. **3D and Materials:** Extrude & Bevel, Revolve, Inflate and Rotate with lighting and materials, using a software
|
||||
renderer in its own crate (layering allows it below L6), with output in SVG/PDF as rasters or projected vectors.
|
||||
The largest single gap. 50–80 h.
|
||||
@@ -70,7 +73,9 @@ Ordered by how much each gap blocks someone from switching. Sizes are one-agent
|
||||
6. **Automation:** Variables (data merge), a scripting surface over the command registry, batch. 8–12 h.
|
||||
7. **Views and windows:** multiple windows and arrange, Consolidate All Windows, video
|
||||
rulers. 15–25 h.
|
||||
8. **Hardening at scale:** a corpus of real-world SVG/PDF/EPS files, idle-machine perf budgets, Windows, Linux and
|
||||
8. **Hardening at scale:** a corpus of real-world SVG/PDF/EPS files, Affinity files from more versions and platforms
|
||||
(the native reader is checked against 22 public files' own thumbnails; synthetic one-feature oracles saved by
|
||||
Affinity itself are still missing), idle-machine perf budgets, Windows, Linux and
|
||||
browser QA, pen tablets on Wayland and pen pressure beyond Windows (winit 0.31's tablet input, #491), accessibility, packaging. 50–80 h.
|
||||
|
||||
### Where we're going
|
||||
@@ -160,6 +165,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).
|
||||
- Affinity documents in (AF.1–AF.3): `.af` (Affinity 3) and `.afdesign`/`.afphoto`/`.afpub` (Affinity 1 and 2, container versions 8–12) open and place as editable art: layers and sublayers, groups (pass-through or isolated), artboards (with their names and backgrounds) and Publisher pages side by side, curves with live corners, parametric shapes (rectangles with corner radii, ellipses, polygons, stars, pies, triangles, trapezoids), compound shapes, solid/linear/elliptical gradient fills and strokes (weight, dashes, alignment, scale-with-object), clipping by nesting, vector and pixel masks, artistic and frame text with fonts, sizes, tracking, leading and alignment, placed images (their original JPEG/PNG) and pixel layers (8/16-bit RGB, CMYK), opacity, visibility, locking and blend modes, at the document's resolution in points. Effects, adjustments, brushes and pressure profiles, transparency gradients, master pages, symbol instances (imported as their cached picture), special shapes (cloud, heart, cog…, imported as ellipses) and Affinity-only blend modes are named in the import warning. A file whose native data can't be read opens as its embedded preview with the reason, and that preview can't be placed. Save never writes back over the Affinity file and there is no Affinity export. 22 public CC0/MIT/Apache-2.0 files, pinned and sha256-verified (`cargo xtask corpus --affinity`), must render within a few levels of the thumbnails Affinity saved in them; [scope, provenance and limits](crates/affinity/README.md).
|
||||
- Placed documents: File › Place of a `.vectorcraft` file links to it by default (`file.place`'s `link`): one locked object showing an artboard of the document (or its art's bounds), read the way placing it without Link reads it, its symbols, patterns and images kept apart from the document's; without Link (or from bytes, not a file) it is an editable copy as before. It stays vectors in every output (SVG, PDF, EPS, EMF, DXF), and so do placed documents inside it, read from their own files up to 8 levels deep (documents that place each other stop there). On screen they draw from bitmaps cached per file and zoom step, shared by every copy and made in the background (a placeholder until ready), so a sheet of them redraws in about a millisecond; exports and prints draw them exactly. Like linked images, a save keeps only a preview of each (JPEG when opaque) unless Include Linked Files; output, and zooming past the preview, read the file again, falling back to the preview with a warning when it is gone or changed. Saves for older versions write them as plain art. Links check, update, relink, Update Links on open, Link Info (the artboard's size), Placement Options, Break Link (also Embed, Object › Expand: the editable copy placing without Link gives, its resources joining the document), Edit Original (the document in a new tab) and the Links panel cover them; saving a document updates the open documents that place it. The native format (v4) keeps data of 8 KB or more after the JSON instead of base64 in it.
|
||||
- Place and the Links panel, Package, File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398); on Linux the Paste menu checks the clipboard off the UI thread, so an X11 owner that never answers can't freeze the window (#510); screenshots and other copied pictures paste as embedded images at the view centre everywhere, the web included (#457).
|
||||
- **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped.
|
||||
@@ -174,7 +180,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
| M1 | Selection, transform, layers, MCP | ✅ mostly done (transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending) | 4–6 |
|
||||
| M2 | Drawing tools + smart guides | ✅ mostly done (Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; construction widget/refinements pending) | 12–18 |
|
||||
| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork; gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool, Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries. Left: freeform and mixed spot/process gradients export as stops or process colours to SVG/PDF; confirm the Scale Strokes & Effects default | 4–8 |
|
||||
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info. Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, polish | 3–6 |
|
||||
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info; AF.1–AF.3 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish | 3–6 (existing scope; Affinity export unestimated) |
|
||||
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
|
||||
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open) | 3–6 |
|
||||
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, per-paragraph paragraph attributes, Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel, 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 |
|
||||
@@ -210,7 +216,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
|
||||
| Strokes, brushes, width profiles | 5 | 88% | brush options depth, brush libraries (generated in code), brushes driven by pen tilt, bearing and barrel rotation (6D art pens) | 12–20 |
|
||||
| Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 |
|
||||
| Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap, SVG Filters | 6–10 |
|
||||
| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 20% | ~55 Photoshop-style filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Stylize, Texture, Video) and the Effect Gallery; Document Raster Effects Settings and raster effects in PDF are done | 28–42 |
|
||||
| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 24% | ~52 Photoshop-style filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Stylize, Texture, Video), Smart Blur's Edge Only / Overlay Edge modes and the Effect Gallery; Document Raster Effects Settings, raster effects in PDF, the pixel-filter pipeline, Radial Blur, Smart Blur and Unsharp Mask are done | 26–40 |
|
||||
| 3D and Materials | 4 | 0% | Extrude & Bevel, Revolve, Inflate, Rotate, lighting, materials (software renderer) | 50–80 |
|
||||
| Type core | 9 | 80% | Middle Eastern features beyond bidi layout and Paragraph Direction (Character Direction, digit types, kashidas, the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); Hyphenation… and Justification… options (Every-line Composer for justified and ragged text, per object, is done; it is tuned by eye, not yet compared side by side with Illustrator; mixed type sizes in a paragraph still break single-line), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee | 14–19 |
|
||||
| Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), kinsoku sets and the Mojikumi Settings dialog —, proportional vertical metrics, manual tate-chu-yoko), variable font axis sliders (named instances are styles), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 |
|
||||
@@ -219,7 +225,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
|
||||
| Layers, artboards, document setup | 5 | 82% | Layers panel: a confirmation before deleting layers that hold art, Collect for Export, row context menus, the Layer Options dialog for several rows with mixed values, Paste Remembers Layers for sublayers, drag-scrolling a long panel while dragging rows; artboard preset polish (Rearrange All Artboards, Document Setup, New Document and the rest of the Layers panel are done) | 4–7 |
|
||||
| View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, Snap to Pixel/Glyph (print tiling is done) | 9–14 |
|
||||
| Guides, grids, smart guides, snapping, rulers | 3 | 85% | global/video rulers, Spacing Guides' distance readouts and spacing for resized or drawn art, Smart Guides' angle and distance readouts while drawing, Alt swapping a ruler guide's orientation, ruler guides selected together with art (marquee, Shift-click), ruler guides as Layers rows with their own lock and hide (#451: Lock Guides locks them all; Make Guides paths are Layers rows already) (dragging existing guides is done, #414; new guides snapping to side midpoints and artboards, and artboard guides, #451; the drawing tools snapping, with Construction Guides, #506; Spacing Guides for moves, #394) | 2–4 |
|
||||
| File formats | 6 | 85% | DWG (no open spec: DXF instead), PSD placement as layers, EPS checked against more real files users send (Inkscape files open as vectors; other generators' constructs are tested with programs written from the PostScript Language Reference only; CID-keyed CJK type and `CCITTFaxDecode` images not read), legacy `.ai` files saved without the prolog that defines their operators (their own operator set isn't read yet), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers and presets are done | 6–10 |
|
||||
| File formats | 6 | 85% | Affinity export, and the rest of Affinity import (effects, adjustments, brushes, master pages, live symbol instances), DWG (no open spec: DXF instead), PSD placement as layers, EPS checked against more real files users send (Inkscape files open as vectors; other generators' constructs are tested with programs written from the PostScript Language Reference only; CID-keyed CJK type and `CCITTFaxDecode` images not read), legacy `.ai` files saved without the prolog that defines their operators (their own operator set isn't read yet), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers, presets and Affinity import are done | 6–10 (existing scope; Affinity export unestimated) |
|
||||
| Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 |
|
||||
| Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done) | 1–2 |
|
||||
| Automation | 3 | 60% | Variables (data merge), scripting surface, batch | 8–12 |
|
||||
@@ -236,7 +242,7 @@ With 4–6 agents working on disjoint crates (as the layering allows) the wall-c
|
||||
|
||||
_Inventories (2026-10-07):_ 13 menu items still stubbed (`todo(…)` in `crates/ui-egui/src/menus.rs`); every tool
|
||||
implemented except Touch Type; 51 panel modules; Illustrator-style live
|
||||
effects ~44/54, Photoshop-style raster effects ~1/56, 3D 0/5; ~2,840 tests; ~221k lines of Rust.
|
||||
effects ~44/54, Photoshop-style raster effects ~4/56, 3D 0/5; ~2,840 tests; ~221k lines of Rust.
|
||||
|
||||
_Where we already beat Illustrator:_ exact curve booleans, off-thread multithreaded rendering, undo that never runs out,
|
||||
lossless SVG/PDF export of live effects with SVG filters, a documented JSON format, the same app on the web, and every
|
||||
|
||||
@@ -364,6 +364,18 @@ fn app_icon() -> egui::IconData {
|
||||
eframe::icon_data::from_png_bytes(png).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Windows shows a window's big icon (`ICON_BIG`) in the taskbar and Alt+Tab; eframe sets only the
|
||||
/// small one (the title bar's), so the taskbar showed a generic icon. Set the big one too.
|
||||
#[cfg(windows)]
|
||||
fn set_taskbar_icon(w: &winit::window::Window) {
|
||||
use winit::platform::windows::WindowExtWindows as _;
|
||||
let icon = app_icon();
|
||||
// An icon that can't be made leaves the generic one: nothing else depends on it.
|
||||
if let Ok(i) = winit::window::Icon::from_rgba(icon.rgba, icon.width, icon.height) {
|
||||
w.set_taskbar_icon(Some(i));
|
||||
}
|
||||
}
|
||||
|
||||
/// "name (backend, kind)" of the adapter the window renders with, for Help › About and bug reports.
|
||||
#[cfg(feature = "wgpu")]
|
||||
fn adapter_summary(info: &eframe::wgpu::AdapterInfo) -> String {
|
||||
@@ -469,6 +481,8 @@ fn main() -> std::process::ExitCode {
|
||||
// Fit the window to its monitor, or put it back where it was (still hidden).
|
||||
if let Some(w) = cc.winit_window() {
|
||||
app.ui.window = Some(window::restore(w, saved_window));
|
||||
#[cfg(windows)]
|
||||
set_taskbar_icon(w);
|
||||
}
|
||||
// User Defined swatch and graphic style libraries live next to the preferences.
|
||||
let swatches = prefs_path().and_then(|p| Some(p.parent()?.join("Swatches").to_string_lossy().to_string()));
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "vectorcraft-affinity"
|
||||
description = "Bounded reader for native Affinity documents: archive, object stream and document model"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
# Synthetic-container builders for tests and fuzz seeds; not an Affinity exporter.
|
||||
synth = []
|
||||
|
||||
[dependencies]
|
||||
crc32fast = { workspace = true }
|
||||
flate2 = { workspace = true }
|
||||
ruzstd = "0.9"
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
@@ -0,0 +1,122 @@
|
||||
# vectorcraft-affinity
|
||||
|
||||
An independent, bounded Rust reader for native Affinity documents: `.af` from Affinity 3 and
|
||||
`.afdesign`, `.afphoto` and `.afpub` from Affinity 1 and 2 (container versions 8 to 12). It reads
|
||||
the archive, the tagged object stream in `doc.dat` and the part of the document model listed below,
|
||||
and hands the engine a document in pixels with every transform composed. It does not write
|
||||
Affinity files.
|
||||
|
||||
## What is imported
|
||||
|
||||
| Affinity | VectorCraft |
|
||||
|---|---|
|
||||
| Pages, Publisher spreads (one or two pages) | artboards, spreads side by side |
|
||||
| Artboards (`ShpN` with `ABEn`), with name, background fill, any transform | artboards plus a clip group with the background |
|
||||
| Layers (`Scop`), groups (`Grup`), pass-through or isolated | layers and sublayers, groups |
|
||||
| Curves (`PCrv`): cubic subpaths, closed flags, live corners (`CnrD`) | paths (multi-subpath curves fill even-odd) |
|
||||
| Rectangles with corner radii (relative or absolute), ellipses, polygons (smooth too), stars, square stars, pies, triangles, trapezoids | paths |
|
||||
| Compound shapes (`Comp`, add and subtract) | one even-odd path |
|
||||
| A shape or curve with children clips them | clip group whose clipping path keeps its fill (behind) and stroke (over) |
|
||||
| Vector masks (`AdCh` curves and shapes) | clip group |
|
||||
| Pixel masks (`MRst`, attached or as a layer in a group) | opacity mask |
|
||||
| Solid fills: RGBA, HSLA, CMYK, grey, Lab (D50) | fill appearance (alpha as fill opacity) |
|
||||
| Linear, elliptical and radial gradients with Affinity's midpoint bias | gradients (the bias as the midpoint where the blend is half way) |
|
||||
| Strokes: weight, scale with object, dash pattern and phase, alignment, miter limit, behind the fill | stroke appearance |
|
||||
| Fill layers (`FRst`) | filled rectangles |
|
||||
| Artistic and frame text: characters, font family/weight/style, size, tracking, fixed leading, colour, paragraph alignment, first baseline | point type and area type |
|
||||
| Placed images (`ImgN`): the original JPEG/PNG embedded in the file | embedded images |
|
||||
| Pixel layers (`Rstr`): RGBA 8/16-bit and CMYK 8-bit tiles, cropped to their content | embedded PNG images |
|
||||
| Embedded documents and symbols (`EmbN`) | the picture Affinity cached of them |
|
||||
| Opacity, visibility, lock, names, the blend modes VectorCraft has | the same |
|
||||
|
||||
Everything else is reported in the import warnings, one line per kind with a count, never
|
||||
dropped silently: layer effects, adjustment layers and live filters, brush and pressure strokes,
|
||||
transparency gradients, fill opacity, bitmap fills, master pages, conical gradients (as radial),
|
||||
special shapes (cloud, heart, cog, callouts, arrows…, imported as their bounding ellipse), corner
|
||||
types other than round, stars with rounded points, several fills or strokes on one object (the
|
||||
active one is used), outlined or scaled text, text fields such as page numbers, frames with
|
||||
columns or a curved outline, grey/Lab/32-bit pixels, CMYK pixels (converted to RGB without the
|
||||
document's profile) and Affinity-only blend modes (Add, Linear Light… as Normal).
|
||||
|
||||
When the native document can't be read at all, the engine opens the embedded PNG preview instead,
|
||||
with a warning that names the reason; that preview can't be placed, used as a template or mined
|
||||
for swatches and styles. Save never writes back over the Affinity file.
|
||||
|
||||
## Provenance and clean-room
|
||||
|
||||
Affinity has no published file-format specification, so this reader is built only from a public
|
||||
description of the format and from public files, never from Affinity itself. This is how the sibling app PhotoCraft already treats formats that
|
||||
are only partly documented:
|
||||
its PSD reader fills the gaps in Adobe's specification from the MIT-licensed psd-tools and
|
||||
ag-psd, and its camera raw decoder recovered Nikon's compression tables by black-box analysis
|
||||
of CC0 sample files.
|
||||
|
||||
* **No Affinity software.** Affinity was never downloaded, installed, run, scripted, screenshotted or
|
||||
disassembled for this work, and nothing from an Affinity installation (program code, resources,
|
||||
presets, fonts, colour profiles) was read. No file was made with Affinity for it: every sample
|
||||
is a document its author published.
|
||||
* **A public, permissively licensed description.** The container and object-stream layout was
|
||||
learned from [VMDevCpp/afread](https://github.com/VMDevCpp/afread) (MIT, at
|
||||
`04b672334a43e3e37ded6b5ffc57af231d589774`, written for container versions 7–11) and re-described
|
||||
in our own words before this Rust code was written; no code was translated. No GPL/AGPL code (such
|
||||
as Inkscape's Affinity extension) was read.
|
||||
* **Every structure checked against public files.** afread doesn't say how its author learned the
|
||||
format, so nothing here rests on it alone: each structure the reader relies on is confirmed by
|
||||
the public documents. Every archive entry carries a CRC-32 that must match, and every field in
|
||||
the object stream names its own type, so a misread layout fails loudly instead of producing
|
||||
plausible wrong data.
|
||||
* **Meaning fitted to Affinity's own pictures.** What shapes, paints, text and pixel data mean was
|
||||
worked out by comparing our render with the thumbnail every Affinity document embeds (Affinity's
|
||||
render of itself), on 176 public documents saved by Affinity 1.x, 2.x and 3.0/3.1 on Windows,
|
||||
macOS and iPad. Most are published under CC0, MIT, BSD, Apache-2.0 or CC BY (-SA); the others
|
||||
(no licence stated, or GPL or non-commercial terms) were only opened locally to compare
|
||||
pictures, never committed or redistributed. The corpus test keeps the comparison for the pinned
|
||||
files, which are CC0, MIT or Apache-2.0 only.
|
||||
* **Nothing of Affinity's is in this repository**: no program code, assets or documents. The test
|
||||
files are fetched at pinned commits and sha256-verified, never committed.
|
||||
* **Read-only.** Nothing is written in Affinity's format; export waits until someone can check
|
||||
written files in Affinity itself.
|
||||
|
||||
The same reader is shared, as an independent copy, with PhotoCraft's `photocraft-affinity`.
|
||||
|
||||
## Validation
|
||||
|
||||
* Unit tests on synthetic containers built by the `synth` feature (stored, zlib and zstd entries,
|
||||
checksums, budgets, cycles, hostile streams, every truncation) and property tests of random
|
||||
mutations.
|
||||
* `cargo xtask corpus --affinity` fetches 22 public CC0/MIT/Apache-2.0 documents, including the
|
||||
four Affinity 3 `.af` files of [samuel-etver/vector-art](https://github.com/samuel-etver/vector-art)
|
||||
(CC0), at pinned commits, each checked against `xtask/affinity-corpus.sha256`. `tests/real_files.rs`
|
||||
parses them; `engine/tests/affinity_corpus.rs` renders each one and compares it with the
|
||||
thumbnail Affinity saved in it (mean difference 0–4.4 of 255 at the thumbnail's size, with a
|
||||
ceiling per file).
|
||||
* `engine/tests/import_fuzz.rs` mutates a synthetic native document's stream and archive and checks
|
||||
that whatever opens also renders and exports; `fuzz/` has `cargo-fuzz` targets for the whole
|
||||
reader (`container`) and the object stream (`stream`):
|
||||
|
||||
```sh
|
||||
cd crates/affinity
|
||||
cargo +nightly fuzz run stream -- -max_total_time=600
|
||||
```
|
||||
|
||||
What has not been verified: files from Affinity builds or platforms outside the public set, files
|
||||
written by other applications, rotated or skewed images against Affinity's render, mask polarity
|
||||
against a render with and without the mask, and anything listed as a warning above. The `synth`
|
||||
builders write only what this reader accepts; Affinity has never opened their output, so they are
|
||||
test fixtures, not an exporter.
|
||||
|
||||
## Safety limits
|
||||
|
||||
Every size is checked before it is allocated: 256 MiB per archive entry and 1 GiB per import by
|
||||
default (`Limits`), a 64 MiB zstd window, 4096 saved revisions, array lengths no longer than the
|
||||
bytes left, 16 777 216 decoded values per document stream (fields and array elements together, counted before
|
||||
anything is allocated: a value takes about 40 bytes in memory however few it took in the file; the largest of
|
||||
189 distinct public documents uses 3.3 million), 384 levels of object nesting, 128 levels of layers, 500 000 layers, four million curve
|
||||
nodes per curve, 64 megapixels per pixel layer (cropped to its content first) and 1024 gradient
|
||||
stops. Every archive entry's CRC-32 and size must match. Malformed input returns an `Error`; the
|
||||
crate has no panics outside tests.
|
||||
|
||||
## Privacy
|
||||
|
||||
Affinity documents can hold the folder they were saved in, user names, original image paths and
|
||||
XMP metadata. This reader never imports those fields.
|
||||
@@ -0,0 +1,5 @@
|
||||
/target
|
||||
/corpus
|
||||
/artifacts
|
||||
/coverage
|
||||
/Cargo.lock
|
||||
@@ -0,0 +1,29 @@
|
||||
[package]
|
||||
name = "vectorcraft-affinity-fuzz"
|
||||
version = "0.0.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[package.metadata]
|
||||
cargo-fuzz = true
|
||||
|
||||
[dependencies]
|
||||
libfuzzer-sys = "0.4"
|
||||
vectorcraft-affinity = { path = "..", features = ["synth"] }
|
||||
|
||||
[[bin]]
|
||||
name = "container"
|
||||
path = "fuzz_targets/container.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "stream"
|
||||
path = "fuzz_targets/stream.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[workspace]
|
||||
members = ["."]
|
||||
@@ -0,0 +1,10 @@
|
||||
#![no_main]
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
|
||||
// The whole native reader (archive, object stream, model) and the preview, on arbitrary bytes.
|
||||
fuzz_target!(|bytes: &[u8]| {
|
||||
let _ = vectorcraft_affinity::container::header(bytes);
|
||||
let _ = vectorcraft_affinity::preview(bytes);
|
||||
let _ = vectorcraft_affinity::read(bytes, vectorcraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20 });
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
#![no_main]
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
|
||||
// The doc.dat object stream and the model built from it, wrapped in a stored archive entry so
|
||||
// mutations reach the tagged grammar instead of the archive's checksum.
|
||||
fuzz_target!(|stream: &[u8]| {
|
||||
let _ = vectorcraft_affinity::stream::parse(stream);
|
||||
let file = vectorcraft_affinity::synth::container(&[("doc.dat", stream, vectorcraft_affinity::synth::Method::Stored)], None);
|
||||
let _ = vectorcraft_affinity::read(&file, vectorcraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20 });
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
//! Typed reads of [`Stream`] objects. Every accessor returns `None` when a field is missing or has
|
||||
//! another type, so the document mapping treats odd data as absent rather than failing.
|
||||
|
||||
use crate::stream::{ObjId, Object, Stream, Tag, Value};
|
||||
|
||||
impl Stream {
|
||||
pub fn object(&self, id: ObjId) -> Option<&Object> {
|
||||
self.objects.get(id)
|
||||
}
|
||||
|
||||
pub fn field(&self, id: ObjId, tag: &[u8; 4]) -> Option<&Value> {
|
||||
self.object(id)?.get(Tag::of(tag))
|
||||
}
|
||||
|
||||
pub fn class(&self, id: ObjId) -> Option<Tag> {
|
||||
self.object(id).map(|o| o.class)
|
||||
}
|
||||
|
||||
pub fn is(&self, id: ObjId, class: &[u8; 4]) -> bool {
|
||||
self.class(id) == Some(Tag::of(class))
|
||||
}
|
||||
|
||||
/// A child object (shared, linked or inline); `None` for null or missing.
|
||||
pub fn obj(&self, id: ObjId, tag: &[u8; 4]) -> Option<ObjId> {
|
||||
match self.field(id, tag)? {
|
||||
Value::Obj(o) => *o,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// An array of objects (nulls skipped).
|
||||
pub fn objs(&self, id: ObjId, tag: &[u8; 4]) -> Vec<ObjId> {
|
||||
match self.field(id, tag) {
|
||||
Some(Value::Array(a)) => a.iter().filter_map(|v| if let Value::Obj(Some(o)) = v { Some(*o) } else { None }).collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn f64(&self, id: ObjId, tag: &[u8; 4]) -> Option<f64> {
|
||||
match self.field(id, tag)? {
|
||||
Value::Float(f) if f.is_finite() => Some(*f),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn int(&self, id: ObjId, tag: &[u8; 4]) -> Option<i64> {
|
||||
match self.field(id, tag)? {
|
||||
Value::Int(i) => Some(*i),
|
||||
Value::UInt(u) => i64::try_from(*u).ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bool(&self, id: ObjId, tag: &[u8; 4]) -> Option<bool> {
|
||||
match self.field(id, tag)? {
|
||||
Value::Bool(b) => Some(*b),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn str(&self, id: ObjId, tag: &[u8; 4]) -> Option<&str> {
|
||||
match self.field(id, tag)? {
|
||||
Value::Str(s) => Some(s),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn enumeration(&self, id: ObjId, tag: &[u8; 4]) -> Option<(u16, u16)> {
|
||||
match self.field(id, tag)? {
|
||||
Value::Enum { id, version } => Some((*id, *version)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A fixed vector of `N` finite floats (points, rectangles, transforms).
|
||||
pub fn floats<const N: usize>(&self, id: ObjId, tag: &[u8; 4]) -> Option<[f64; N]> {
|
||||
match self.field(id, tag)? {
|
||||
Value::Floats(v) if v.len() == N && v.iter().all(|f| f.is_finite()) => {
|
||||
let mut a = [0.0; N];
|
||||
a.copy_from_slice(v);
|
||||
Some(a)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bytes(&self, id: ObjId, tag: &[u8; 4]) -> Option<&[u8]> {
|
||||
match self.field(id, tag)? {
|
||||
Value::Bytes(b) | Value::Blob(b) => Some(b),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn entry(&self, id: ObjId, tag: &[u8; 4]) -> Option<&str> {
|
||||
match self.field(id, tag)? {
|
||||
Value::Entry(s) => Some(s),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Untagged fields of a positional object, in order.
|
||||
pub fn positional(&self, id: ObjId) -> impl Iterator<Item = &Value> {
|
||||
self.object(id).into_iter().flat_map(|o| o.fields.iter().map(|(_, v)| v))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
//! The Affinity archive: header, the chain of file allocation tables (one per save) and
|
||||
//! bounded, CRC-checked extraction of named entries such as `doc.dat` and raster tiles.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::io::Read;
|
||||
|
||||
use crate::Error;
|
||||
|
||||
pub const MAGIC: &[u8; 4] = b"\x00\xffKA";
|
||||
/// Oldest and newest container versions seen in real documents (Affinity 1.x writes 8,
|
||||
/// Affinity 3 writes 12). Version 7 is described by afread but no sample exists.
|
||||
pub const VERSIONS: std::ops::RangeInclusive<u16> = 8..=12;
|
||||
/// `Prsn` (stored byte-reversed): documents. Asset, brush and style libraries share the magic.
|
||||
const DOCUMENT_KIND: &[u8; 4] = b"nsrP";
|
||||
|
||||
/// A save appends one table; real files have one to a few hundred.
|
||||
const MAX_TABLES: usize = 4096;
|
||||
/// zstd window ceiling: real documents use a few MiB; this stops a header from reserving gigabytes.
|
||||
const MAX_ZSTD_WINDOW: u64 = 64 << 20;
|
||||
|
||||
/// Decompression budget for one import. Every entry declares its size, which is checked against
|
||||
/// these limits before anything is allocated, and the decoded length must match it exactly.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Limits {
|
||||
/// Largest single entry (`doc.dat` or one tile).
|
||||
pub max_entry: usize,
|
||||
/// Total bytes extracted over the whole import.
|
||||
pub max_total: usize,
|
||||
}
|
||||
|
||||
impl Default for Limits {
|
||||
fn default() -> Self {
|
||||
Self { max_entry: 256 << 20, max_total: 1 << 30 }
|
||||
}
|
||||
}
|
||||
|
||||
/// The container header, all integers little-endian.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Header {
|
||||
pub version: u16,
|
||||
/// Seen as 0, 4 or 8 in real files; bits 0 and 1 mark container variants that are rejected.
|
||||
pub flags: u16,
|
||||
pub(crate) fat_offset: u64,
|
||||
pub(crate) thumbnail_offset: u64,
|
||||
/// Save time in Unix seconds, as written by the application.
|
||||
pub saved: u32,
|
||||
}
|
||||
|
||||
/// One FAT record, as of the newest table that mentions its id.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Entry {
|
||||
pub id: u32,
|
||||
pub name: String,
|
||||
offset: u64,
|
||||
size: u64,
|
||||
compressed: u64,
|
||||
crc: u32,
|
||||
compression: u8,
|
||||
}
|
||||
|
||||
impl Entry {
|
||||
/// Decompressed size declared by the table.
|
||||
pub fn size(&self) -> u64 {
|
||||
self.size
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Archive<'a> {
|
||||
bytes: &'a [u8],
|
||||
pub header: Header,
|
||||
/// Head revision of every named, non-deleted entry.
|
||||
entries: HashMap<String, Entry>,
|
||||
/// Number of tables (saves) in the chain.
|
||||
pub revisions: usize,
|
||||
extracted: usize,
|
||||
limits: Limits,
|
||||
}
|
||||
|
||||
pub(crate) struct Cursor<'a> {
|
||||
bytes: &'a [u8],
|
||||
pub(crate) pos: usize,
|
||||
}
|
||||
|
||||
impl<'a> Cursor<'a> {
|
||||
pub(crate) fn new(bytes: &'a [u8], pos: usize) -> Self {
|
||||
Self { bytes, pos }
|
||||
}
|
||||
pub(crate) fn remaining(&self) -> usize {
|
||||
self.bytes.len().saturating_sub(self.pos)
|
||||
}
|
||||
pub(crate) fn take(&mut self, n: usize) -> Result<&'a [u8], Error> {
|
||||
let end = self.pos.checked_add(n).ok_or(Error::Malformed("length overflow"))?;
|
||||
let s = self.bytes.get(self.pos..end).ok_or(Error::Malformed("truncated data"))?;
|
||||
self.pos = end;
|
||||
Ok(s)
|
||||
}
|
||||
pub(crate) fn array<const N: usize>(&mut self) -> Result<[u8; N], Error> {
|
||||
let s = self.take(N)?;
|
||||
let mut a = [0u8; N];
|
||||
a.copy_from_slice(s);
|
||||
Ok(a)
|
||||
}
|
||||
pub(crate) fn u8(&mut self) -> Result<u8, Error> {
|
||||
Ok(self.array::<1>()?[0])
|
||||
}
|
||||
pub(crate) fn u16(&mut self) -> Result<u16, Error> {
|
||||
Ok(u16::from_le_bytes(self.array()?))
|
||||
}
|
||||
pub(crate) fn u32(&mut self) -> Result<u32, Error> {
|
||||
Ok(u32::from_le_bytes(self.array()?))
|
||||
}
|
||||
pub(crate) fn u64(&mut self) -> Result<u64, Error> {
|
||||
Ok(u64::from_le_bytes(self.array()?))
|
||||
}
|
||||
}
|
||||
|
||||
fn offset(v: u64) -> Result<usize, Error> {
|
||||
usize::try_from(v).map_err(|_| Error::Malformed("offset beyond addressable memory"))
|
||||
}
|
||||
|
||||
/// True when `bytes` start with the Affinity container signature (documents and libraries).
|
||||
pub fn is_affinity(bytes: &[u8]) -> bool {
|
||||
bytes.starts_with(MAGIC)
|
||||
}
|
||||
|
||||
/// Check the signature, version and document kind.
|
||||
pub fn header(bytes: &[u8]) -> Result<Header, Error> {
|
||||
if !is_affinity(bytes) {
|
||||
return Err(Error::Malformed("missing container signature"));
|
||||
}
|
||||
let mut c = Cursor::new(bytes, 4);
|
||||
let version = c.u16()?;
|
||||
let flags = c.u16()?;
|
||||
if !VERSIONS.contains(&version) {
|
||||
return Err(Error::Unsupported("unknown container version"));
|
||||
}
|
||||
if flags & 3 != 0 {
|
||||
return Err(Error::Unsupported("container variant"));
|
||||
}
|
||||
if c.take(4)? != DOCUMENT_KIND {
|
||||
return Err(Error::Unsupported("this Affinity file is a library (assets, brushes or styles), not a document"));
|
||||
}
|
||||
if c.take(4)? != b"#Inf" {
|
||||
return Err(Error::Malformed("missing information header"));
|
||||
}
|
||||
let fat_offset = c.u64()?;
|
||||
let thumbnail_offset = c.u64()?;
|
||||
c.take(16)?;
|
||||
let saved = u32::try_from(c.u64()?).unwrap_or(0);
|
||||
c.take(8)?;
|
||||
if c.take(4)? != b"Prot" {
|
||||
return Err(Error::Malformed("missing protocol header"));
|
||||
}
|
||||
Ok(Header { version, flags, fat_offset, thumbnail_offset, saved })
|
||||
}
|
||||
|
||||
impl<'a> Archive<'a> {
|
||||
pub fn open(bytes: &'a [u8], limits: Limits) -> Result<Self, Error> {
|
||||
let header = header(bytes)?;
|
||||
// Newest record per id: (table date, table index, record).
|
||||
let mut latest: HashMap<u32, (u64, usize, Option<Entry>)> = HashMap::new();
|
||||
let mut names: HashMap<u32, String> = HashMap::new();
|
||||
let mut seen = HashSet::new();
|
||||
let mut next = header.fat_offset;
|
||||
let mut tables = 0usize;
|
||||
while next != 0 {
|
||||
if !seen.insert(next) {
|
||||
return Err(Error::Malformed("allocation tables form a cycle"));
|
||||
}
|
||||
tables += 1;
|
||||
if tables > MAX_TABLES {
|
||||
return Err(Error::Limit("too many saved revisions"));
|
||||
}
|
||||
let mut c = Cursor::new(bytes, offset(next)?);
|
||||
let tag = c.array::<4>()?;
|
||||
let level = match &tag {
|
||||
b"#FAT" => 1,
|
||||
b"#FT2" => 2,
|
||||
b"#FT3" => 3,
|
||||
b"#FT4" => 4,
|
||||
_ => return Err(Error::Malformed("missing allocation table")),
|
||||
};
|
||||
next = c.u64()?;
|
||||
let date = c.u64()?;
|
||||
c.take(24)?;
|
||||
let files = c.u32()?;
|
||||
c.take(8)?;
|
||||
let dirs = c.u16()?;
|
||||
c.take(1)?;
|
||||
for _ in 0..files {
|
||||
let id = c.u32()?;
|
||||
let flag = c.u8()?;
|
||||
let mut record = None;
|
||||
match flag {
|
||||
0 | 1 => {
|
||||
let offset = c.u64()?;
|
||||
let size = c.u64()?;
|
||||
let compressed = c.u64()?;
|
||||
let crc = c.u32()?;
|
||||
let stored = c.u8()?;
|
||||
if level >= 2 {
|
||||
c.take(4)?;
|
||||
}
|
||||
if level >= 4 {
|
||||
c.take(4)?;
|
||||
}
|
||||
// Old tables number the methods; newer ones store the method byte itself.
|
||||
let compression = if level <= 2 {
|
||||
match stored {
|
||||
1 => 0x01,
|
||||
2 => 0x41,
|
||||
3 => 0x81,
|
||||
4 => 0xC1,
|
||||
_ => 0,
|
||||
}
|
||||
} else {
|
||||
stored
|
||||
};
|
||||
record = Some(Entry { id, name: String::new(), offset, size, compressed, crc, compression });
|
||||
}
|
||||
2 => {}
|
||||
_ => return Err(Error::Malformed("unknown allocation record")),
|
||||
}
|
||||
if flag == 0 {
|
||||
let len = usize::from(c.u16()?);
|
||||
let name = String::from_utf8_lossy(c.take(len)?).into_owned();
|
||||
names.entry(id).or_insert(name);
|
||||
}
|
||||
// Equal dates: the first table in the chain (the newest) wins, as in afread.
|
||||
let replace = latest.get(&id).is_none_or(|(d, _, _)| date > *d);
|
||||
if replace {
|
||||
latest.insert(id, (date, tables, record));
|
||||
}
|
||||
}
|
||||
for _ in 0..dirs {
|
||||
let len = usize::from(c.u16()?);
|
||||
c.take(10)?;
|
||||
c.take(len)?;
|
||||
}
|
||||
}
|
||||
let mut entries = HashMap::new();
|
||||
for (id, (_, _, record)) in latest {
|
||||
if let (Some(mut e), Some(name)) = (record, names.get(&id)) {
|
||||
e.name = name.clone();
|
||||
entries.insert(e.name.clone(), e);
|
||||
}
|
||||
}
|
||||
Ok(Self { bytes, header, entries, revisions: tables, extracted: 0, limits })
|
||||
}
|
||||
|
||||
pub fn entry(&self, name: &str) -> Option<&Entry> {
|
||||
self.entries.get(name)
|
||||
}
|
||||
|
||||
pub fn entry_names(&self) -> impl Iterator<Item = &str> {
|
||||
self.entries.keys().map(String::as_str)
|
||||
}
|
||||
|
||||
/// Bytes extracted so far, counted against [`Limits::max_total`].
|
||||
pub fn extracted(&self) -> usize {
|
||||
self.extracted
|
||||
}
|
||||
|
||||
/// Extract an entry by name: decompress, undo the byte predictor and verify the CRC.
|
||||
pub fn read(&mut self, name: &str) -> Result<Vec<u8>, Error> {
|
||||
let e = self.entries.get(name).ok_or(Error::Malformed("missing archive entry"))?.clone();
|
||||
let size = usize::try_from(e.size).map_err(|_| Error::Limit("archive entry too large"))?;
|
||||
if size > self.limits.max_entry {
|
||||
return Err(Error::Limit("archive entry too large"));
|
||||
}
|
||||
let total = self.extracted.checked_add(size).ok_or(Error::Limit("document too large"))?;
|
||||
if total > self.limits.max_total {
|
||||
return Err(Error::Limit("document too large"));
|
||||
}
|
||||
let mut c = Cursor::new(self.bytes, offset(e.offset)?);
|
||||
if c.take(4)? != b"#Fil" {
|
||||
return Err(Error::Malformed("archive entry has no data block"));
|
||||
}
|
||||
let method = e.compression & 3;
|
||||
let stored_len = if matches!(method, 1 | 2) { e.compressed } else { e.size };
|
||||
let data = c.take(offset(stored_len)?)?;
|
||||
let mut out = match method {
|
||||
1 => inflate(data, size)?,
|
||||
2 => unzstd(data, size)?,
|
||||
_ => data.to_vec(),
|
||||
};
|
||||
if out.len() != size {
|
||||
return Err(Error::Malformed("archive entry size mismatch"));
|
||||
}
|
||||
unpredict(&mut out, e.compression)?;
|
||||
if crc32fast::hash(&out) != e.crc {
|
||||
return Err(Error::Malformed("archive entry checksum mismatch"));
|
||||
}
|
||||
self.extracted = total;
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Read at most `size + 1` bytes so a stream that decodes past its declared size is caught
|
||||
/// without unbounded allocation.
|
||||
fn read_bounded(mut r: impl Read, size: usize) -> Result<Vec<u8>, Error> {
|
||||
let mut out = Vec::with_capacity(size.min(64 << 20));
|
||||
let cap = u64::try_from(size).unwrap_or(u64::MAX).saturating_add(1);
|
||||
r.by_ref().take(cap).read_to_end(&mut out).map_err(|_| Error::Malformed("corrupt compressed entry"))?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn inflate(data: &[u8], size: usize) -> Result<Vec<u8>, Error> {
|
||||
read_bounded(flate2::read::ZlibDecoder::new(data), size)
|
||||
}
|
||||
|
||||
fn unzstd(data: &[u8], size: usize) -> Result<Vec<u8>, Error> {
|
||||
let mut src = data;
|
||||
let mut out = Vec::new();
|
||||
// Entries may hold several concatenated frames.
|
||||
while !src.is_empty() {
|
||||
let left = size.checked_sub(out.len()).ok_or(Error::Malformed("archive entry size mismatch"))?;
|
||||
let decoder = ruzstd::decoding::StreamingDecoder::new_with_max_window_size(&mut src, MAX_ZSTD_WINDOW)
|
||||
.map_err(|_| Error::Malformed("corrupt compressed entry"))?;
|
||||
let frame = read_bounded(decoder, left)?;
|
||||
if frame.is_empty() {
|
||||
return Err(Error::Malformed("corrupt compressed entry"));
|
||||
}
|
||||
out.extend_from_slice(&frame);
|
||||
if out.len() > size {
|
||||
return Err(Error::Malformed("archive entry size mismatch"));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Undo the byte predictor selected by the high bits of the compression byte.
|
||||
fn unpredict(b: &mut [u8], compression: u8) -> Result<(), Error> {
|
||||
match (compression & 0xC0, compression & 0x20 != 0) {
|
||||
(0x00, _) => {}
|
||||
(0x40, _) => byte_delta(b),
|
||||
(0x80, false) => {
|
||||
let mut prev = 0u16;
|
||||
for pair in b.chunks_exact_mut(2) {
|
||||
let v = u16::from_le_bytes([pair[0], pair[1]]).wrapping_add(prev);
|
||||
pair.copy_from_slice(&v.to_le_bytes());
|
||||
prev = v;
|
||||
}
|
||||
}
|
||||
(0x80, true) => {
|
||||
byte_delta(b);
|
||||
// A 64 KiB tile of 16-bit samples is stored as all high bytes, then all low bytes.
|
||||
if b.len() == 0x10000 {
|
||||
let planes = b.to_vec();
|
||||
let (hi, lo) = planes.split_at(0x8000);
|
||||
for (i, out) in b.chunks_exact_mut(4).enumerate() {
|
||||
let (Some(h), Some(l)) = (hi.get(2 * i..2 * i + 2), lo.get(2 * i..2 * i + 2)) else {
|
||||
return Err(Error::Malformed("tile size"));
|
||||
};
|
||||
out.copy_from_slice(&[l[0], h[0], l[1], h[1]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => return Err(Error::Unsupported("archive entry predictor")),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn byte_delta(b: &mut [u8]) {
|
||||
let mut prev = 0u8;
|
||||
for v in b {
|
||||
*v = v.wrapping_add(prev);
|
||||
prev = *v;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
//! Outlines of curves (`PCvD` node lists), parametric shapes (`Shpe` objects) and compounds, in
|
||||
//! node space.
|
||||
|
||||
use crate::model::{Affine, Path, Point, Reader, SubPath};
|
||||
use crate::stream::{ObjId, Tag, Value};
|
||||
|
||||
/// Nodes per curve object.
|
||||
const MAX_NODES: usize = 4_000_000;
|
||||
|
||||
/// The node-space outline of a curve, shape or compound node, and whether it fills even-odd.
|
||||
/// Instances (master-page or symbol copies) take the geometry of their master.
|
||||
pub(crate) fn outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine) -> Option<(Path, bool)> {
|
||||
let s = r.s;
|
||||
match &class.0.to_be_bytes() {
|
||||
b"PCrv" | b"TxtC" => {
|
||||
let crvs = s.obj(id, b"Crvs").or_else(|| s.obj(id, b"MCrM").and_then(|m| s.obj(m, b"Crvs")))?;
|
||||
let path = curves(r, crvs)?;
|
||||
let multi = path.subpaths.len() > 1;
|
||||
Some((path, multi))
|
||||
}
|
||||
b"Comp" => {
|
||||
// One path of all the operands' contours in the compound's space, filled even-odd: this
|
||||
// matches add and subtract operands in every public sample; a true union of overlapping
|
||||
// added operands would differ.
|
||||
let mut path = Path::default();
|
||||
for c in s.objs(id, b"Chld") {
|
||||
let local = s.floats::<6>(c, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY);
|
||||
let class = s.class(c)?;
|
||||
if s.enumeration(c, b"ComO").is_some_and(|(op, _)| op != 0 && op != 2) {
|
||||
r.warn("compound shapes that intersect or exclude (imported as add)");
|
||||
}
|
||||
if let Some((p, _)) = outline(r, c, class, local.then(world)) {
|
||||
path.subpaths.extend(p.transformed(local).subpaths);
|
||||
}
|
||||
}
|
||||
Some((path, true))
|
||||
}
|
||||
_ => {
|
||||
let (shape, box_owner) = match s.obj(id, b"Shpe") {
|
||||
Some(sh) => (sh, id),
|
||||
None => {
|
||||
let master = s.obj(id, b"ShpM")?;
|
||||
(s.obj(master, b"Shpe")?, master)
|
||||
}
|
||||
};
|
||||
let b = s.floats::<4>(box_owner, b"ShpB")?;
|
||||
crate::shapes::shape(r, shape, b, world).map(|p| (p, false))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `PCvD.Data`: positional `[u8, u32 count, then per subpath: bool closed, 18-byte records]`.
|
||||
/// A record is (f64 x, f64 y, u8 kind, u8 role): role 0 is an anchor, 1 the out-handle of the
|
||||
/// anchor before it, 2 the in-handle of the anchor after it. A closed subpath repeats its first
|
||||
/// anchor at the end. `CnrD` live corners are rounded on the stored polyline.
|
||||
pub(crate) fn curves(r: &mut Reader, crvs: ObjId) -> Option<Path> {
|
||||
let s = r.s;
|
||||
let data = s.obj(crvs, b"Data")?;
|
||||
let mut fields = s.positional(data);
|
||||
fields.next()?;
|
||||
let count = match fields.next()? {
|
||||
Value::UInt(n) => usize::try_from(*n).ok()?,
|
||||
_ => return None,
|
||||
};
|
||||
let corners = s.obj(crvs, b"CnrD").and_then(|c| corner_radii(s.positional(c).collect()));
|
||||
let mut path = Path::default();
|
||||
let mut seen = 0usize;
|
||||
for index in 0..count {
|
||||
let closed = match fields.next()? {
|
||||
Value::Bool(b) => *b,
|
||||
_ => return None,
|
||||
};
|
||||
let Value::Array(records) = fields.next()? else { return None };
|
||||
seen = seen.checked_add(records.len())?;
|
||||
if seen > MAX_NODES {
|
||||
r.warn("curves with more than four million nodes");
|
||||
return None;
|
||||
}
|
||||
let anchors = anchors(records)?;
|
||||
let radii = if index == 0 { corners.as_ref() } else { None };
|
||||
if let Some(sp) = subpath(&anchors, closed, radii) {
|
||||
path.subpaths.push(sp);
|
||||
}
|
||||
}
|
||||
if corners.is_some() && count > 1 {
|
||||
r.warn("rounded corners on curves with several subpaths (only the first is rounded)");
|
||||
}
|
||||
Some(path)
|
||||
}
|
||||
|
||||
struct Anchor {
|
||||
p: Point,
|
||||
inh: Option<Point>,
|
||||
out: Option<Point>,
|
||||
/// Index of the anchor's record (live corners refer to records).
|
||||
record: usize,
|
||||
}
|
||||
|
||||
fn record(v: &Value) -> Option<(Point, u8)> {
|
||||
let Value::Bytes(b) = v else { return None };
|
||||
if b.len() != 18 {
|
||||
return None;
|
||||
}
|
||||
let x = f64::from_le_bytes(b.get(0..8)?.try_into().ok()?);
|
||||
let y = f64::from_le_bytes(b.get(8..16)?.try_into().ok()?);
|
||||
if !x.is_finite() || !y.is_finite() {
|
||||
return None;
|
||||
}
|
||||
Some((Point { x, y }, *b.get(17)?))
|
||||
}
|
||||
|
||||
fn anchors(records: &[Value]) -> Option<Vec<Anchor>> {
|
||||
let mut out: Vec<Anchor> = Vec::with_capacity(records.len() / 3 + 1);
|
||||
let mut pending_in = None;
|
||||
for (i, v) in records.iter().enumerate() {
|
||||
let (p, role) = record(v)?;
|
||||
match role {
|
||||
0 => {
|
||||
out.push(Anchor { p, inh: pending_in.take(), out: None, record: i });
|
||||
}
|
||||
1 => {
|
||||
// A leading out-handle without an anchor is edit state only.
|
||||
if let Some(a) = out.last_mut() {
|
||||
a.out = Some(p);
|
||||
}
|
||||
}
|
||||
2 => pending_in = Some(p),
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn subpath(anchors: &[Anchor], closed: bool, radii: Option<&Vec<(usize, f64)>>) -> Option<SubPath> {
|
||||
let first = anchors.first()?;
|
||||
if let Some(radii) = radii.filter(|r| !r.is_empty()) {
|
||||
let corner = |a: &Anchor| radii.iter().filter(|(rec, _)| *rec == a.record).map(|(_, r)| *r).fold(0.0, f64::max);
|
||||
let polyline = anchors.iter().all(|a| a.inh.is_none_or(|h| h == a.p) && a.out.is_none_or(|h| h == a.p));
|
||||
if polyline {
|
||||
let mut pts: Vec<(Point, f64)> = anchors.iter().map(|a| (a.p, corner(a))).collect();
|
||||
// A closed subpath repeats its first anchor; its radius may be on either copy.
|
||||
if closed
|
||||
&& pts.len() > 1
|
||||
&& pts.first().map(|p| p.0) == pts.last().map(|p| p.0)
|
||||
&& let Some((_, r)) = pts.pop()
|
||||
&& let Some(f) = pts.first_mut()
|
||||
{
|
||||
f.1 = f.1.max(r);
|
||||
}
|
||||
return Some(crate::shapes::fillet(&pts, closed));
|
||||
}
|
||||
}
|
||||
let segments = anchors
|
||||
.windows(2)
|
||||
.filter_map(|w| {
|
||||
let [a, b] = w else { return None };
|
||||
Some([a.out.unwrap_or(a.p), b.inh.unwrap_or(b.p), b.p])
|
||||
})
|
||||
.collect();
|
||||
Some(SubPath { start: first.p, segments, closed })
|
||||
}
|
||||
|
||||
/// `CnrD`: positional `[u8, i32[] record indices, f64[] radii, u8[] types]`.
|
||||
fn corner_radii(fields: Vec<&Value>) -> Option<Vec<(usize, f64)>> {
|
||||
let (Some(Value::Array(idx)), Some(Value::Array(rad))) = (fields.get(1), fields.get(2)) else { return None };
|
||||
Some(
|
||||
idx.iter()
|
||||
.zip(rad)
|
||||
.filter_map(|(i, r)| match (i, r) {
|
||||
(Value::Int(i), Value::Float(r)) if r.is_finite() && *r > 0.0 => Some((usize::try_from(*i).ok()?, *r)),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//! Reader for native Affinity documents (`.af`, and `.afdesign`/`.afphoto`/`.afpub` from
|
||||
//! Affinity 1 and 2): the archive container, the tagged object stream in `doc.dat`, and the
|
||||
//! subset of the document model VectorCraft imports.
|
||||
//!
|
||||
//! Layouts follow the MIT-licensed afread reference and were checked against public documents
|
||||
//! saved by Affinity 1.x to 3.x (container versions 8 to 12); see the crate README for provenance
|
||||
//! and the exact supported subset. Nothing here writes Affinity files.
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
use std::fmt;
|
||||
|
||||
mod access;
|
||||
pub mod container;
|
||||
mod geometry;
|
||||
pub mod model;
|
||||
pub mod paint;
|
||||
pub mod preview;
|
||||
mod raster;
|
||||
mod shapes;
|
||||
pub mod stream;
|
||||
#[cfg(any(test, feature = "synth"))]
|
||||
pub mod synth;
|
||||
mod text;
|
||||
|
||||
pub use container::{Archive, Limits, MAGIC, is_affinity};
|
||||
pub use model::{Document, read};
|
||||
pub use preview::{MAX_PREVIEW_BYTES, MAX_PREVIEW_DIMENSION, Preview, preview};
|
||||
|
||||
/// Why a file could not be read. Messages are short reasons; callers add advice.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Error {
|
||||
Malformed(&'static str),
|
||||
Unsupported(&'static str),
|
||||
Limit(&'static str),
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Malformed(s) => write!(f, "damaged Affinity file: {s}"),
|
||||
Self::Unsupported(s) => write!(f, "unsupported Affinity file: {s}"),
|
||||
Self::Limit(s) => write!(f, "Affinity file exceeds a safety limit: {s}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,594 @@
|
||||
//! The document model VectorCraft imports: spreads, artboards, layers, groups, curves, shapes,
|
||||
//! text and images, with transforms composed into document space. Anything else is reported
|
||||
//! by [`Document::warnings`] instead of being dropped silently.
|
||||
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
|
||||
use crate::paint::{self, Paint, Stroke};
|
||||
use crate::stream::{ObjId, Stream, Tag};
|
||||
use crate::{Archive, Error, Limits, stream};
|
||||
|
||||
/// Affine map `x' = a·x + c·y + e`, `y' = b·x + d·y + f` (kurbo's coefficient order).
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Affine(pub [f64; 6]);
|
||||
|
||||
impl Affine {
|
||||
pub const IDENTITY: Self = Self([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
|
||||
|
||||
/// Affinity's `Xfrm`, stored row by row: (a, c, e, b, d, f).
|
||||
pub(crate) fn from_xfrm(m: [f64; 6]) -> Self {
|
||||
Self([m[0], m[3], m[1], m[4], m[2], m[5]])
|
||||
}
|
||||
|
||||
pub fn then(self, outer: Self) -> Self {
|
||||
let [a, b, c, d, e, f] = self.0;
|
||||
let [oa, ob, oc, od, oe, of] = outer.0;
|
||||
Self([oa * a + oc * b, ob * a + od * b, oa * c + oc * d, ob * c + od * d, oa * e + oc * f + oe, ob * e + od * f + of])
|
||||
}
|
||||
|
||||
pub fn apply(&self, p: Point) -> Point {
|
||||
let [a, b, c, d, e, f] = self.0;
|
||||
Point { x: a * p.x + c * p.y + e, y: b * p.x + d * p.y + f }
|
||||
}
|
||||
|
||||
/// Geometric mean of the axis scales: how much a stroke width or font size grows.
|
||||
pub fn scale(&self) -> f64 {
|
||||
let [a, b, c, d, ..] = self.0;
|
||||
(a * d - b * c).abs().sqrt()
|
||||
}
|
||||
|
||||
pub fn is_finite(&self) -> bool {
|
||||
self.0.iter().all(|v| v.is_finite())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Point {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Rect {
|
||||
pub x0: f64,
|
||||
pub y0: f64,
|
||||
pub x1: f64,
|
||||
pub y1: f64,
|
||||
}
|
||||
|
||||
/// One cubic Bézier subpath, in document pixels.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct SubPath {
|
||||
pub start: Point,
|
||||
/// (control 1, control 2, end) per segment.
|
||||
pub segments: Vec<[Point; 3]>,
|
||||
pub closed: bool,
|
||||
}
|
||||
|
||||
/// An outline in document pixels.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct Path {
|
||||
pub subpaths: Vec<SubPath>,
|
||||
}
|
||||
|
||||
impl Path {
|
||||
pub fn transformed(&self, m: Affine) -> Self {
|
||||
Self {
|
||||
subpaths: self
|
||||
.subpaths
|
||||
.iter()
|
||||
.map(|s| SubPath {
|
||||
start: m.apply(s.start),
|
||||
segments: s.segments.iter().map(|seg| seg.map(|p| m.apply(p))).collect(),
|
||||
closed: s.closed,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Affinity's blend modes, numbered by the `Blnd` enum.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum Blend {
|
||||
#[default]
|
||||
Normal,
|
||||
PassThrough,
|
||||
Darken,
|
||||
DarkerColor,
|
||||
Multiply,
|
||||
ColorBurn,
|
||||
Lighten,
|
||||
LighterColor,
|
||||
Screen,
|
||||
ColorDodge,
|
||||
Add,
|
||||
Overlay,
|
||||
SoftLight,
|
||||
HardLight,
|
||||
VividLight,
|
||||
PinLight,
|
||||
LinearLight,
|
||||
HardMix,
|
||||
Difference,
|
||||
Exclusion,
|
||||
Subtract,
|
||||
Hue,
|
||||
Saturation,
|
||||
Luminosity,
|
||||
Color,
|
||||
Average,
|
||||
Negation,
|
||||
Reflect,
|
||||
Glow,
|
||||
Erase,
|
||||
}
|
||||
|
||||
impl Blend {
|
||||
/// The enum (id, version) pairs observed in a document whose layers are named after their mode.
|
||||
fn from_enum(id: u16, version: u16) -> Option<Self> {
|
||||
use Blend::*;
|
||||
Some(match (id, version) {
|
||||
(0, _) => Normal,
|
||||
(1, 0) => Darken,
|
||||
(2, 1) => DarkerColor,
|
||||
(2, 0) => Multiply,
|
||||
(3, 0) => ColorBurn,
|
||||
(4, 0) => Lighten,
|
||||
(6, 1) => LighterColor,
|
||||
(5, 0) => Screen,
|
||||
(6, 0) => ColorDodge,
|
||||
(7, 0) => Add,
|
||||
(8, 0) => Overlay,
|
||||
(9, 0) => SoftLight,
|
||||
(10, 0) => HardLight,
|
||||
(11, 0) => VividLight,
|
||||
(12, 0) => PinLight,
|
||||
(15, 1) => LinearLight,
|
||||
(13, 0) => HardMix,
|
||||
(14, 0) => Difference,
|
||||
(15, 0) => Exclusion,
|
||||
(16, 0) => Subtract,
|
||||
(17, 0) => Hue,
|
||||
(18, 0) => Saturation,
|
||||
(19, 0) => Luminosity,
|
||||
(20, 0) => Color,
|
||||
(21, 0) => Average,
|
||||
(22, 0) => Negation,
|
||||
(23, 0) => Reflect,
|
||||
(24, 0) => Glow,
|
||||
(25, 0) => Erase,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A run of text with one set of character attributes.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct TextRun {
|
||||
pub text: String,
|
||||
/// PostScript name and family of the chosen font.
|
||||
pub postscript: String,
|
||||
pub family: String,
|
||||
/// CSS weight (400 regular, 700 bold).
|
||||
pub weight: i64,
|
||||
pub italic: bool,
|
||||
/// Font size in document pixels, before the node's transform.
|
||||
pub size: f64,
|
||||
/// Tracking in em.
|
||||
pub tracking: f64,
|
||||
/// Fixed line pitch in document pixels, when the run overrides automatic leading.
|
||||
pub leading: Option<f64>,
|
||||
pub fill: Paint,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Align {
|
||||
Left,
|
||||
Center,
|
||||
Right,
|
||||
Justify,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Text {
|
||||
pub runs: Vec<TextRun>,
|
||||
pub align: Align,
|
||||
/// Artistic text: the first baseline's anchor point in node space (start, centre or end of the
|
||||
/// line according to `align`). Frame text: the frame box in node space.
|
||||
pub anchor: Point,
|
||||
pub frame: Option<Rect>,
|
||||
/// Node space to document pixels.
|
||||
pub transform: Affine,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Pixels {
|
||||
/// Straight-alpha RGBA, 8 bits per channel, row by row.
|
||||
Rgba8(Vec<u8>),
|
||||
/// The original file a placed image was made from (PNG, JPEG…), as embedded.
|
||||
Encoded(Vec<u8>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Image {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub pixels: Pixels,
|
||||
/// Pixel space (0..w, 0..h) to document pixels.
|
||||
pub transform: Affine,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Kind {
|
||||
/// Layer container (`Scop`).
|
||||
Layer,
|
||||
Group,
|
||||
/// Artboard: a rectangle that clips its children; `background` paints behind them.
|
||||
Artboard {
|
||||
rect: Rect,
|
||||
background: Vec<Paint>,
|
||||
},
|
||||
/// Curve, parametric shape or compound shape: an outline with its fills and strokes.
|
||||
/// Children of a shape are clipped by its outline (painted over its fills, under its strokes).
|
||||
Shape {
|
||||
path: Path,
|
||||
fills: Vec<Paint>,
|
||||
strokes: Vec<Stroke>,
|
||||
even_odd: bool,
|
||||
},
|
||||
Text(Text),
|
||||
Image(Image),
|
||||
/// Something this reader does not import; its children are still imported.
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Node {
|
||||
pub class: Tag,
|
||||
pub name: String,
|
||||
pub visible: bool,
|
||||
pub locked: bool,
|
||||
pub opacity: f64,
|
||||
pub blend: Blend,
|
||||
pub kind: Kind,
|
||||
/// Vector mask in document pixels: the node shows only inside it (nonzero fill).
|
||||
pub mask: Option<Path>,
|
||||
/// Pixel mask: grey levels (white shows the node, black hides it); outside it the node is hidden.
|
||||
pub pixel_mask: Option<Image>,
|
||||
/// Bottom to top.
|
||||
pub children: Vec<Node>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Spread {
|
||||
/// Spread bounds in document pixels (the canvas, or the union of the artboards).
|
||||
pub bounds: Rect,
|
||||
/// Pages of a Publisher-style spread (one, or two facing pages), in document pixels.
|
||||
pub pages: Vec<Rect>,
|
||||
/// Transparent background; otherwise the page is white.
|
||||
pub transparent: bool,
|
||||
/// Bottom to top.
|
||||
pub nodes: Vec<Node>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Document {
|
||||
/// Pixels per inch: all geometry is in document pixels at this resolution.
|
||||
pub dpi: f64,
|
||||
pub spreads: Vec<Spread>,
|
||||
/// Application that last saved the file, as stored (`Affinity 3.0.2`), when present.
|
||||
pub saved_by: Option<String>,
|
||||
/// Content that was not imported or was approximated, one line per kind, deduplicated.
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Deepest layer nesting followed; deeper content is reported, not imported.
|
||||
const MAX_TREE_DEPTH: usize = 128;
|
||||
/// Total layer nodes imported.
|
||||
const MAX_NODES: usize = 500_000;
|
||||
|
||||
/// Read a document: archive, `doc.dat` and the model.
|
||||
pub fn read(bytes: &[u8], limits: Limits) -> Result<Document, Error> {
|
||||
let mut archive = Archive::open(bytes, limits)?;
|
||||
let doc = archive.read("doc.dat")?;
|
||||
let s = stream::parse(&doc)?;
|
||||
Reader::new(&s, &mut archive).document()
|
||||
}
|
||||
|
||||
pub(crate) struct Reader<'s, 'a, 'b> {
|
||||
pub(crate) s: &'s Stream,
|
||||
pub(crate) archive: &'b mut Archive<'a>,
|
||||
pub(crate) dpi: f64,
|
||||
warnings: BTreeMap<String, usize>,
|
||||
nodes: usize,
|
||||
active: HashSet<ObjId>,
|
||||
}
|
||||
|
||||
impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
fn new(s: &'s Stream, archive: &'b mut Archive<'a>) -> Self {
|
||||
Self { s, archive, dpi: 72.0, warnings: BTreeMap::new(), nodes: 0, active: HashSet::new() }
|
||||
}
|
||||
|
||||
pub(crate) fn warn(&mut self, what: impl Into<String>) {
|
||||
*self.warnings.entry(what.into()).or_default() += 1;
|
||||
}
|
||||
|
||||
fn document(mut self) -> Result<Document, Error> {
|
||||
let s = self.s;
|
||||
let root = s.root;
|
||||
self.dpi = s.obj(root, b"UVCn").and_then(|u| s.f64(u, b"UPPI")).filter(|d| *d > 0.0 && *d < 100_000.0).unwrap_or(72.0);
|
||||
let saved_by = s.obj(root, b"NVer").map(|v| {
|
||||
let product = s.str(v, b"Prod").unwrap_or("Affinity");
|
||||
match (s.int(v, b"Majr"), s.int(v, b"Minr"), s.int(v, b"Bild")) {
|
||||
(Some(a), Some(b), Some(c)) => format!("{product} {a}.{b}.{c}"),
|
||||
_ => product.to_string(),
|
||||
}
|
||||
});
|
||||
let doc = s.obj(root, b"DocR").ok_or(Error::Malformed("document has no root node"))?;
|
||||
let mut spreads = Vec::new();
|
||||
for spread in s.objs(doc, b"Chld") {
|
||||
if !s.is(spread, b"Sprd") {
|
||||
self.warn("an unknown kind of page");
|
||||
continue;
|
||||
}
|
||||
let pages = pages(s, spread);
|
||||
let default_size =
|
||||
s.floats::<2>(doc, b"DfSz").filter(|[w, h]| *w > 0.0 && *h > 0.0).map(|[w, h]| Rect { x0: 0.0, y0: 0.0, x1: w, y1: h });
|
||||
let bounds = s.floats::<4>(spread, b"SprB").map(rect).or_else(|| pages.iter().copied().reduce(union)).or(default_size).unwrap_or(Rect {
|
||||
x0: 0.0,
|
||||
y0: 0.0,
|
||||
x1: 0.0,
|
||||
y1: 0.0,
|
||||
});
|
||||
let transparent = s.bool(spread, b"SprT").unwrap_or(false);
|
||||
let (nodes, mask) = self.children(spread, Affine::IDENTITY, 0)?;
|
||||
if mask.is_some() {
|
||||
self.warn("mask layers directly on a page (imported without them)");
|
||||
}
|
||||
spreads.push(Spread { bounds, pages, transparent, nodes });
|
||||
}
|
||||
if spreads.is_empty() {
|
||||
return Err(Error::Malformed("document has no pages"));
|
||||
}
|
||||
if !s.objs(doc, b"MpCh").is_empty() {
|
||||
self.warn("master pages (their content is not placed on the pages that use them)");
|
||||
}
|
||||
let warnings = self.warnings.into_iter().map(|(w, n)| if n > 1 { format!("{w} ({n}×)") } else { w }).collect();
|
||||
Ok(Document { dpi: self.dpi, spreads, saved_by, warnings })
|
||||
}
|
||||
|
||||
/// The children of `parent`, and the pixel mask a mask layer among them puts on the parent.
|
||||
fn children(&mut self, parent: ObjId, world: Affine, depth: usize) -> Result<(Vec<Node>, Option<Image>), Error> {
|
||||
let s = self.s;
|
||||
let mut out = Vec::new();
|
||||
let mut mask = None;
|
||||
for c in s.objs(parent, b"Chld") {
|
||||
if s.is(c, b"MRst") {
|
||||
// A mask layer inside a group or layer masks that container.
|
||||
if s.bool(c, b"Visi") == Some(false) {
|
||||
continue;
|
||||
}
|
||||
let local = s.floats::<6>(c, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY);
|
||||
match crate::raster::mask(self, c, local.then(world))? {
|
||||
Some(m) if mask.is_none() => mask = Some(m),
|
||||
Some(_) => self.warn("several pixel masks on one layer (only the first is used)"),
|
||||
None => {}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(n) = self.node(c, world, depth + 1)? {
|
||||
out.push(n);
|
||||
}
|
||||
}
|
||||
Ok((out, mask))
|
||||
}
|
||||
|
||||
fn node(&mut self, id: ObjId, parent: Affine, depth: usize) -> Result<Option<Node>, Error> {
|
||||
let s = self.s;
|
||||
if depth > MAX_TREE_DEPTH {
|
||||
self.warn("layers nested deeper than 128 levels");
|
||||
return Ok(None);
|
||||
}
|
||||
self.nodes += 1;
|
||||
if self.nodes > MAX_NODES {
|
||||
return Err(Error::Limit("too many layers"));
|
||||
}
|
||||
// A node that contains itself (a shared object linked back into its own subtree).
|
||||
if !self.active.insert(id) {
|
||||
self.warn("a layer that contains itself");
|
||||
return Ok(None);
|
||||
}
|
||||
let local = s.floats::<6>(id, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY);
|
||||
let world = local.then(parent);
|
||||
let class = s.class(id).unwrap_or(Tag(0));
|
||||
let name = s.str(id, b"Desc").unwrap_or_default().to_string();
|
||||
let visible = s.bool(id, b"Visi").unwrap_or(true);
|
||||
let locked = s.bool(id, b"Edtb").is_some_and(|e| !e);
|
||||
let opacity = s.f64(id, b"Opac").map_or(1.0, |o| o.clamp(0.0, 1.0));
|
||||
let blend = match s.enumeration(id, b"Blnd") {
|
||||
None if s.bool(id, b"PasT") != Some(false) && is_container(class) => Blend::PassThrough,
|
||||
None => Blend::Normal,
|
||||
Some((i, v)) => Blend::from_enum(i, v).unwrap_or_else(|| {
|
||||
self.warn("an unknown blend mode (imported as Normal)");
|
||||
Blend::Normal
|
||||
}),
|
||||
};
|
||||
let kind = if !world.is_finite() {
|
||||
self.warn("a layer with an invalid transform");
|
||||
Kind::Unsupported
|
||||
} else {
|
||||
self.kind(id, class, world, parent)?
|
||||
};
|
||||
// A compound's children are its operands, already merged into its outline.
|
||||
let (children, child_mask) = if class == Tag::of(b"Comp") { (Vec::new(), None) } else { self.children(id, world, depth)? };
|
||||
let (mask, attached_mask) = self.attached_masks(id, world)?;
|
||||
let pixel_mask = attached_mask.or(child_mask);
|
||||
self.active.remove(&id);
|
||||
if s.objs(id, b"FiEf").iter().any(|e| s.bool(*e, b"Enab") != Some(false)) {
|
||||
self.warn("layer effects (shadows, glows, outlines, bevels…)");
|
||||
}
|
||||
Ok(Some(Node { class, name, visible, locked, opacity, blend, kind, mask, pixel_mask, children }))
|
||||
}
|
||||
|
||||
fn kind(&mut self, id: ObjId, class: Tag, world: Affine, parent: Affine) -> Result<Kind, Error> {
|
||||
let s = self.s;
|
||||
Ok(match &class.0.to_be_bytes() {
|
||||
b"Scop" => Kind::Layer,
|
||||
b"Grup" => Kind::Group,
|
||||
b"PCrv" | b"ShpN" | b"Comp" | b"SNEN" | b"SNRR" | b"ShRN" => {
|
||||
if class == Tag::of(b"ShpN") && s.bool(id, b"ABEn") == Some(true) {
|
||||
return Ok(self.artboard(id, world));
|
||||
}
|
||||
let Some((local, even_odd)) = crate::geometry::outline(self, id, class, world) else {
|
||||
self.warn(format!("a {} that could not be read", describe(class)));
|
||||
return Ok(Kind::Unsupported);
|
||||
};
|
||||
let (fills, strokes) = paint::node_paint(self, id, world);
|
||||
Kind::Shape { path: local.transformed(world), fills, strokes, even_odd }
|
||||
}
|
||||
b"TxtA" | b"TxtF" | b"TxtC" => match crate::text::read(self, id, world) {
|
||||
Some(t) => Kind::Text(t),
|
||||
None => {
|
||||
self.warn("text whose story could not be read (master-page text, or an unknown layout)");
|
||||
Kind::Unsupported
|
||||
}
|
||||
},
|
||||
b"ImgN" | b"Rstr" => match crate::raster::node_image(self, id, world)? {
|
||||
Some(img) => Kind::Image(img),
|
||||
None => Kind::Unsupported,
|
||||
},
|
||||
b"EmbN" => match crate::raster::embedded(self, id, world)? {
|
||||
Some(img) => {
|
||||
self.warn("embedded documents and symbols (imported as pictures of them)");
|
||||
Kind::Image(img)
|
||||
}
|
||||
None => {
|
||||
self.warn("embedded documents and symbols without a cached picture");
|
||||
Kind::Unsupported
|
||||
}
|
||||
},
|
||||
b"FRst" => {
|
||||
// A fill layer paints its whole bitmap area; its gradient lives in page space.
|
||||
let b = s.obj(id, b"Bitm");
|
||||
let size = |t: &[u8; 4]| b.and_then(|b| s.int(b, t)).map(|v| v as f64).filter(|v| *v > 0.0);
|
||||
match (size(b"BmpW"), size(b"BmpH")) {
|
||||
(Some(w), Some(h)) => {
|
||||
let local = Path { subpaths: vec![crate::shapes::rectangle(0.0, 0.0, w, h)] };
|
||||
let fills = paint::fills(self, id, parent, b"BFFl");
|
||||
Kind::Shape { path: local.transformed(world), fills, strokes: Vec::new(), even_odd: false }
|
||||
}
|
||||
_ => {
|
||||
self.warn("fill layers without an area");
|
||||
Kind::Unsupported
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
self.warn(format!("{} layers", describe(class)));
|
||||
Kind::Unsupported
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Vector shapes attached to a node (`AdCh`) mask it: it shows only inside their outlines;
|
||||
/// an attached mask layer is a pixel mask. Adjustments attached the same way are reported.
|
||||
fn attached_masks(&mut self, id: ObjId, world: Affine) -> Result<(Option<Path>, Option<Image>), Error> {
|
||||
let s = self.s;
|
||||
let attached = s.objs(id, b"AdCh");
|
||||
let mut mask: Option<Path> = None;
|
||||
let mut pixels: Option<Image> = None;
|
||||
for a in attached {
|
||||
let class = s.class(a).unwrap_or(Tag(0));
|
||||
if s.bool(a, b"Visi") == Some(false) {
|
||||
continue;
|
||||
}
|
||||
if class == Tag::of(b"PCrv") || class == Tag::of(b"ShpN") || class == Tag::of(b"Comp") {
|
||||
let local = s.floats::<6>(a, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY).then(world);
|
||||
if let Some((p, _)) = crate::geometry::outline(self, a, class, local) {
|
||||
mask.get_or_insert_with(Path::default).subpaths.extend(p.transformed(local).subpaths);
|
||||
}
|
||||
} else if class == Tag::of(b"MRst") {
|
||||
let local = s.floats::<6>(a, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY).then(world);
|
||||
match crate::raster::mask(self, a, local)? {
|
||||
Some(m) if pixels.is_none() => pixels = Some(m),
|
||||
Some(_) => self.warn("several pixel masks on one layer (only the first is used)"),
|
||||
None => {}
|
||||
}
|
||||
} else {
|
||||
self.warn(format!("{} layers attached to other layers (imported without them)", describe(class)));
|
||||
}
|
||||
}
|
||||
Ok((mask, pixels))
|
||||
}
|
||||
|
||||
fn artboard(&mut self, id: ObjId, world: Affine) -> Kind {
|
||||
let s = self.s;
|
||||
let b = s.floats::<4>(id, b"ShpB").map(rect).unwrap_or(Rect { x0: 0.0, y0: 0.0, x1: 0.0, y1: 0.0 });
|
||||
let corners =
|
||||
[Point { x: b.x0, y: b.y0 }, Point { x: b.x1, y: b.y0 }, Point { x: b.x1, y: b.y1 }, Point { x: b.x0, y: b.y1 }].map(|p| world.apply(p));
|
||||
let [a, b2, c, d, ..] = world.0;
|
||||
if b2.abs() > 1e-9 || c.abs() > 1e-9 || a <= 0.0 || d <= 0.0 {
|
||||
self.warn("a rotated or flipped artboard (imported at its bounding box)");
|
||||
}
|
||||
let xs = corners.map(|p| p.x);
|
||||
let ys = corners.map(|p| p.y);
|
||||
let rect = Rect {
|
||||
x0: xs.iter().copied().fold(f64::INFINITY, f64::min),
|
||||
y0: ys.iter().copied().fold(f64::INFINITY, f64::min),
|
||||
x1: xs.iter().copied().fold(f64::NEG_INFINITY, f64::max),
|
||||
y1: ys.iter().copied().fold(f64::NEG_INFINITY, f64::max),
|
||||
};
|
||||
let background = paint::fills(self, id, world, b"BFFl");
|
||||
Kind::Artboard { rect, background }
|
||||
}
|
||||
}
|
||||
|
||||
/// Page rectangles: `SpMd.PagR[].rctp` (Publisher 2, Affinity 3) or `SprB` split into `PagC`
|
||||
/// equal pages (Publisher 1 facing spreads).
|
||||
fn pages(s: &Stream, spread: ObjId) -> Vec<Rect> {
|
||||
if let Some(md) = s.obj(spread, b"SpMd") {
|
||||
let v: Vec<Rect> =
|
||||
s.objs(md, b"PagR").into_iter().filter_map(|p| s.floats::<4>(p, b"rctp").map(rect)).filter(|r| r.x1 > r.x0 && r.y1 > r.y0).collect();
|
||||
if !v.is_empty() {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
match (s.floats::<4>(spread, b"SprB").map(rect), s.int(spread, b"PagC")) {
|
||||
(Some(b), Some(n @ 2..=16)) => {
|
||||
let w = (b.x1 - b.x0) / n as f64;
|
||||
(0..n).map(|i| Rect { x0: b.x0 + w * i as f64, x1: b.x0 + w * (i + 1) as f64, ..b }).collect()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn union(a: Rect, b: Rect) -> Rect {
|
||||
Rect { x0: a.x0.min(b.x0), y0: a.y0.min(b.y0), x1: a.x1.max(b.x1), y1: a.y1.max(b.y1) }
|
||||
}
|
||||
|
||||
fn is_container(class: Tag) -> bool {
|
||||
class == Tag::of(b"Scop") || class == Tag::of(b"Grup")
|
||||
}
|
||||
|
||||
pub(crate) fn rect(v: [f64; 4]) -> Rect {
|
||||
Rect { x0: v[0], y0: v[1], x1: v[2], y1: v[3] }
|
||||
}
|
||||
|
||||
/// A readable name for a layer class in warnings.
|
||||
pub(crate) fn describe(class: Tag) -> &'static str {
|
||||
match &class.0.to_be_bytes() {
|
||||
b"PCrv" => "curve",
|
||||
b"ShpN" | b"SNEN" | b"SNRR" | b"ShRN" => "shape",
|
||||
b"Comp" => "compound",
|
||||
b"Rstr" => "pixel",
|
||||
b"ImgN" => "image",
|
||||
b"EmbN" => "embedded document",
|
||||
b"MPIN" => "master page instance",
|
||||
b"FRst" => "fill",
|
||||
b"MRst" => "mask",
|
||||
b"Slic" | b"SlcP" => "slice",
|
||||
b"3DLA" => "3D",
|
||||
[_, _, b'R', b'A'] => "adjustment",
|
||||
_ => "unknown",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
//! Fills, strokes and colours. Two schemas exist: Affinity 1 (`BFil` fill, `PFil` stroke paint,
|
||||
//! `LSty` stroke style) and Affinity 2/3 (`BFFl`, `LIFl` and `LILn` lists with a current index).
|
||||
|
||||
use crate::model::{Affine, Reader};
|
||||
use crate::stream::{ObjId, Value};
|
||||
|
||||
/// A colour as stored: RGB and grey values are encoded in the document's RGB space (sRGB unless
|
||||
/// the document embeds another profile), CMYK values go through its CMYK profile. Alpha is straight.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum Color {
|
||||
Rgb {
|
||||
r: f64,
|
||||
g: f64,
|
||||
b: f64,
|
||||
a: f64,
|
||||
},
|
||||
Cmyk {
|
||||
c: f64,
|
||||
m: f64,
|
||||
y: f64,
|
||||
k: f64,
|
||||
a: f64,
|
||||
},
|
||||
Gray {
|
||||
v: f64,
|
||||
a: f64,
|
||||
},
|
||||
/// CIE L*a*b* (D50).
|
||||
Lab {
|
||||
l: f64,
|
||||
a: f64,
|
||||
b: f64,
|
||||
alpha: f64,
|
||||
},
|
||||
}
|
||||
|
||||
impl Color {
|
||||
pub fn alpha(&self) -> f64 {
|
||||
match self {
|
||||
Color::Rgb { a, .. } | Color::Cmyk { a, .. } | Color::Gray { a, .. } => *a,
|
||||
Color::Lab { alpha, .. } => *alpha,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum GradientKind {
|
||||
Linear,
|
||||
/// Elliptical and radial: distance from the centre in unit space.
|
||||
Radial,
|
||||
/// Angle around the centre.
|
||||
Conical,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Stop {
|
||||
pub offset: f64,
|
||||
pub color: Color,
|
||||
/// Affinity's midpoint bias for the segment from this stop to the next (0.5 = even blend).
|
||||
pub bias: f64,
|
||||
}
|
||||
|
||||
impl Stop {
|
||||
/// Where, between this stop (0) and the next (1), the blend reaches half way: the bias curve
|
||||
/// fitted to Affinity's renders is `t^k` (bias ≥ 0.5) or `1-(1-t)^k`, with `k = 1 + 8·|bias-0.5|`.
|
||||
pub fn half_point(&self) -> f64 {
|
||||
let k = 1.0 + 8.0 * (self.bias - 0.5).abs();
|
||||
let h = 0.5f64.powf(1.0 / k);
|
||||
if self.bias >= 0.5 { h } else { 1.0 - h }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Gradient {
|
||||
pub kind: GradientKind,
|
||||
pub stops: Vec<Stop>,
|
||||
/// Unit space to document pixels: linear runs from unit (0,0) to (1,0); radial is the unit
|
||||
/// circle; conical starts along unit +x and turns towards unit +y.
|
||||
pub transform: Affine,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub enum Paint {
|
||||
#[default]
|
||||
None,
|
||||
Solid(Color),
|
||||
Gradient(Gradient),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Cap {
|
||||
Butt,
|
||||
Round,
|
||||
Square,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Join {
|
||||
Miter,
|
||||
Round,
|
||||
Bevel,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Align {
|
||||
Center,
|
||||
Inside,
|
||||
Outside,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Stroke {
|
||||
pub paint: Paint,
|
||||
/// Width in document pixels.
|
||||
pub width: f64,
|
||||
pub cap: Cap,
|
||||
pub join: Join,
|
||||
pub miter_limit: f64,
|
||||
pub align: Align,
|
||||
/// Dash and gap lengths in document pixels, and the phase.
|
||||
pub dash: Option<(Vec<f64>, f64)>,
|
||||
/// Painted below the fill instead of above it.
|
||||
pub behind: bool,
|
||||
}
|
||||
|
||||
/// Gradient stops per gradient.
|
||||
const MAX_STOPS: usize = 1024;
|
||||
|
||||
fn floats(v: &Value) -> Option<Vec<f64>> {
|
||||
match v {
|
||||
Value::Floats(f) => Some(f.clone()),
|
||||
Value::Bytes(b) if b.len() % 4 == 0 => Some(b.chunks_exact(4).map(|c| f64::from(f32::from_le_bytes([c[0], c[1], c[2], c[3]]))).collect()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A colour object (`RGBA`, `HSLA`, `CMYK`, `GRAY`, `LABA`, `Pant`).
|
||||
pub(crate) fn color(r: &mut Reader, id: ObjId) -> Option<Color> {
|
||||
let s = r.s;
|
||||
let class = s.class(id)?;
|
||||
let col = || -> Option<Vec<f64>> {
|
||||
let v = floats(s.field(id, b"_col")?)?;
|
||||
v.iter().all(|f| f.is_finite()).then_some(v)
|
||||
};
|
||||
Some(match &class.0.to_be_bytes() {
|
||||
b"RGBA" => {
|
||||
let v = col()?;
|
||||
Color::Rgb { r: *v.first()?, g: *v.get(1)?, b: *v.get(2)?, a: v.get(3)?.clamp(0.0, 1.0) }
|
||||
}
|
||||
b"HSLA" => {
|
||||
let v = col()?;
|
||||
let (r2, g, b) = hsl(*v.first()?, *v.get(1)?, *v.get(2)?);
|
||||
Color::Rgb { r: r2, g, b, a: v.get(3)?.clamp(0.0, 1.0) }
|
||||
}
|
||||
b"CMYK" => {
|
||||
let v = col()?;
|
||||
Color::Cmyk { c: *v.first()?, m: *v.get(1)?, y: *v.get(2)?, k: *v.get(3)?, a: v.get(4)?.clamp(0.0, 1.0) }
|
||||
}
|
||||
b"GRAY" => {
|
||||
let v = col()?;
|
||||
Color::Gray { v: *v.first()?, a: v.get(1)?.clamp(0.0, 1.0) }
|
||||
}
|
||||
b"LABA" => {
|
||||
let b = s.bytes(id, b"_col")?;
|
||||
let u = |i: usize| -> Option<f64> { Some(f64::from(u16::from_le_bytes([*b.get(2 * i)?, *b.get(2 * i + 1)?])) / 65535.0) };
|
||||
Color::Lab { l: 100.0 * u(0)?, a: 255.0 * u(1)? - 128.0, b: 255.0 * u(2)? - 128.0, alpha: u(3)? }
|
||||
}
|
||||
b"Pant" => {
|
||||
r.warn("spot colours (imported as their RGB equivalent)");
|
||||
return s.obj(id, b"srgb").and_then(|c| color(r, c)).or_else(|| s.obj(id, b"base").and_then(|c| color(r, c)));
|
||||
}
|
||||
b"RegC" => Color::Cmyk { c: 1.0, m: 1.0, y: 1.0, k: 1.0, a: 1.0 },
|
||||
_ => {
|
||||
r.warn("colours in an unknown model");
|
||||
return None;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn hsl(h: f64, s: f64, l: f64) -> (f64, f64, f64) {
|
||||
let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
|
||||
let hp = (h.rem_euclid(1.0)) * 6.0;
|
||||
let x = c * (1.0 - (hp % 2.0 - 1.0).abs());
|
||||
let (r, g, b) = match hp as u32 {
|
||||
0 => (c, x, 0.0),
|
||||
1 => (x, c, 0.0),
|
||||
2 => (0.0, c, x),
|
||||
3 => (0.0, x, c),
|
||||
4 => (x, 0.0, c),
|
||||
_ => (c, 0.0, x),
|
||||
};
|
||||
let m = l - c / 2.0;
|
||||
(r + m, g + m, b + m)
|
||||
}
|
||||
|
||||
/// A fill descriptor (`FDsc`) or, in Affinity 1 files, a bare `FilS`; `local` maps the node's
|
||||
/// space to document pixels.
|
||||
pub(crate) fn descriptor(r: &mut Reader, id: ObjId, local: Affine) -> Option<Paint> {
|
||||
let s = r.s;
|
||||
let (fill, unit) = if s.is(id, b"FDsc") {
|
||||
let unit = s.floats::<6>(id, b"FDeX").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY);
|
||||
(s.obj(id, b"FDeF")?, unit)
|
||||
} else {
|
||||
(id, Affine::IDENTITY)
|
||||
};
|
||||
match &s.class(fill)?.0.to_be_bytes() {
|
||||
b"FilN" => Some(Paint::None),
|
||||
b"FilS" => Some(Paint::Solid(color(r, s.obj(fill, b"Colr")?)?)),
|
||||
b"FilG" => {
|
||||
let kind = match s.enumeration(fill, b"Type").map(|(i, _)| i) {
|
||||
Some(0) | None => GradientKind::Linear,
|
||||
Some(1 | 2) => GradientKind::Radial,
|
||||
Some(3) => GradientKind::Conical,
|
||||
Some(_) => {
|
||||
r.warn("gradients of an unknown kind (imported as linear)");
|
||||
GradientKind::Linear
|
||||
}
|
||||
};
|
||||
let grad = s.obj(fill, b"Grad")?;
|
||||
let cols = s.objs(grad, b"Cols");
|
||||
let posn: Vec<Vec<f64>> = match s.field(grad, b"Posn") {
|
||||
Some(Value::Array(a)) => a.iter().filter_map(floats).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let mut stops = Vec::new();
|
||||
for (c, p) in cols.iter().zip(&posn).take(MAX_STOPS) {
|
||||
let color = color(r, *c)?;
|
||||
let offset = p.first().copied().filter(|v| v.is_finite()).unwrap_or(0.0).clamp(0.0, 1.0);
|
||||
let bias = p.get(1).copied().filter(|v| v.is_finite()).unwrap_or(0.5).clamp(0.0, 1.0);
|
||||
stops.push(Stop { offset, color, bias });
|
||||
}
|
||||
if stops.is_empty() {
|
||||
return Some(Paint::None);
|
||||
}
|
||||
Some(Paint::Gradient(Gradient { kind, stops, transform: unit.then(local) }))
|
||||
}
|
||||
b"FilB" => {
|
||||
r.warn("bitmap and pattern fills (left empty)");
|
||||
Some(Paint::None)
|
||||
}
|
||||
_ => {
|
||||
r.warn("fills of an unknown kind (left empty)");
|
||||
Some(Paint::None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn current(r: &Reader, id: ObjId, list: &[u8; 4], index: &[u8; 4]) -> Option<ObjId> {
|
||||
let items = r.s.objs(id, list);
|
||||
let i = r.s.int(id, index).and_then(|i| usize::try_from(i).ok()).unwrap_or(0);
|
||||
items.get(i).or_else(|| items.first()).copied()
|
||||
}
|
||||
|
||||
/// The fills of `list` (artboard backgrounds use `BFFl`).
|
||||
pub(crate) fn fills(r: &mut Reader, id: ObjId, local: Affine, list: &[u8; 4]) -> Vec<crate::paint::Paint> {
|
||||
let d = current(r, id, list, b"BFCr").or_else(|| r.s.obj(id, b"BFil"));
|
||||
d.and_then(|d| descriptor(r, d, local)).filter(|p| *p != Paint::None).into_iter().collect()
|
||||
}
|
||||
|
||||
/// A node's fill and stroke. `local` maps the node's space to document pixels.
|
||||
pub(crate) fn node_paint(r: &mut Reader, id: ObjId, local: Affine) -> (Vec<Paint>, Vec<Stroke>) {
|
||||
let s = r.s;
|
||||
let fills = fills(r, id, local, b"BFFl");
|
||||
if r.s.objs(id, b"BFFl").len() > 1 || r.s.objs(id, b"LIFl").len() > 1 {
|
||||
r.warn("several fills or strokes on one object (only the active one is imported)");
|
||||
}
|
||||
if s.f64(id, b"FOpc").is_some_and(|o| o < 0.999) {
|
||||
r.warn("fill opacity (imported at full fill opacity)");
|
||||
}
|
||||
if s.obj(id, b"Trns").is_some() {
|
||||
r.warn("transparency gradients (imported without them)");
|
||||
}
|
||||
let (pen, style) = if s.field(id, b"LIFl").is_some() {
|
||||
(current(r, id, b"LIFl", b"LICr"), current(r, id, b"LILn", b"LICr"))
|
||||
} else {
|
||||
(s.obj(id, b"PFil"), s.obj(id, b"LSty"))
|
||||
};
|
||||
let behind = matches!(s.field(id, b"DrwO"), Some(Value::Array(a)) if a.first() == Some(&Value::Enum { id: 1, version: 0 }));
|
||||
let stroke = match (pen, style) {
|
||||
(Some(pen), Some(style)) => stroke(r, pen, style, local, behind),
|
||||
_ => None,
|
||||
};
|
||||
(fills, stroke.into_iter().collect())
|
||||
}
|
||||
|
||||
fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, behind: bool) -> Option<Stroke> {
|
||||
let s = r.s;
|
||||
let paint = descriptor(r, pen, local)?;
|
||||
if paint == Paint::None {
|
||||
return None;
|
||||
}
|
||||
let lsty = if s.is(ldsc, b"LDsc") { s.obj(ldsc, b"LDeL")? } else { ldsc };
|
||||
let weight = s.f64(lsty, b"Wght").filter(|w| *w > 0.0 && *w < 1e7)?;
|
||||
// "Scale with object": the weight follows the object's transform; otherwise it is absolute.
|
||||
let scales = s.bool(ldsc, b"LDSc").unwrap_or(true);
|
||||
let width = if scales { weight * local.scale() } else { weight };
|
||||
let data = s.bytes(lsty, b"Data").unwrap_or_default();
|
||||
let miter_limit = data.get(0..8).and_then(|b| b.try_into().ok()).map(f64::from_le_bytes).filter(|m| m.is_finite() && *m >= 1.0).unwrap_or(4.0);
|
||||
// Cap and join bytes are only partly understood: (0, 0) is butt/miter in every clean sample;
|
||||
// (2, 2) ends plain lines square and brush lines round; joins import round, which never
|
||||
// looks wrong.
|
||||
let (cap, join) = match (data.get(8).copied(), data.get(9).copied(), data.get(10).copied()) {
|
||||
(Some(0), Some(0), _) => (Cap::Butt, Join::Miter),
|
||||
(Some(0), Some(_), _) => (Cap::Butt, Join::Round),
|
||||
(Some(2), Some(2), Some(3)) => (Cap::Round, Join::Round),
|
||||
(Some(2), Some(2), _) => (Cap::Square, Join::Round),
|
||||
_ => (Cap::Round, Join::Round),
|
||||
};
|
||||
let align = match s.int(ldsc, b"LDSa") {
|
||||
Some(1) => Align::Inside,
|
||||
Some(2) => Align::Outside,
|
||||
_ => Align::Center,
|
||||
};
|
||||
let dash = match s.field(lsty, b"Patn") {
|
||||
Some(Value::Array(a)) if data.get(10) == Some(&2) || !a.is_empty() => {
|
||||
let mut v: Vec<f64> = a.iter().filter_map(|x| if let Value::Float(f) = x { Some(*f) } else { None }).collect();
|
||||
while v.len() >= 2 && v.iter().rev().take(2).all(|x| *x == 0.0) {
|
||||
v.truncate(v.len() - 2);
|
||||
}
|
||||
let valid = !v.is_empty() && v.iter().all(|x| x.is_finite() && *x >= 0.0) && v.iter().any(|x| *x > 0.0);
|
||||
valid.then(|| (v.iter().map(|x| x * width).collect(), s.f64(lsty, b"Phse").unwrap_or(0.0) * width))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if s.obj(lsty, b"Brus").is_some() || s.obj(ldsc, b"LDeP").is_some() {
|
||||
r.warn("brush and pressure strokes (imported as plain strokes)");
|
||||
}
|
||||
Some(Stroke { paint, width, cap, join, miter_limit, align, dash, behind })
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
//! The indexed PNG thumbnail Affinity stores next to the archive: Affinity's own render of the
|
||||
//! document, at most 512 pixels on the long side in real files, never the document itself.
|
||||
|
||||
use crate::Error;
|
||||
use crate::container::header;
|
||||
|
||||
pub const MAX_PREVIEW_BYTES: usize = 16 << 20;
|
||||
pub const MAX_PREVIEW_DIMENSION: u32 = 4096;
|
||||
|
||||
/// Borrowed embedded thumbnail, at its own dimensions, never the native document dimensions.
|
||||
#[derive(Debug)]
|
||||
pub struct Preview<'a> {
|
||||
pub png: &'a [u8],
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
fn bytes_at(bytes: &[u8], offset: usize, length: usize) -> Result<&[u8], Error> {
|
||||
let end = offset.checked_add(length).ok_or(Error::Malformed("offset overflow"))?;
|
||||
bytes.get(offset..end).ok_or(Error::Malformed("truncated record"))
|
||||
}
|
||||
|
||||
fn le32(bytes: &[u8], offset: usize) -> Result<u32, Error> {
|
||||
let b = bytes_at(bytes, offset, 4)?;
|
||||
Ok(u32::from_le_bytes(b.try_into().map_err(|_| Error::Malformed("integer"))?))
|
||||
}
|
||||
|
||||
fn be32(bytes: &[u8], offset: usize) -> Result<u32, Error> {
|
||||
let b = bytes_at(bytes, offset, 4)?;
|
||||
Ok(u32::from_be_bytes(b.try_into().map_err(|_| Error::Malformed("PNG integer"))?))
|
||||
}
|
||||
|
||||
/// Read only the indexed `Thmb` record (identical in container versions 8 to 12), checking every PNG chunk CRC. Compressed
|
||||
/// pixels must still be validated by the consuming image decoder, with allocation limits.
|
||||
pub fn preview(bytes: &[u8]) -> Result<Preview<'_>, Error> {
|
||||
let h = header(bytes)?;
|
||||
if h.thumbnail_offset == 0 {
|
||||
return Err(Error::Unsupported("no embedded preview"));
|
||||
}
|
||||
let offset = usize::try_from(h.thumbnail_offset).map_err(|_| Error::Malformed("thumbnail offset overflow"))?;
|
||||
if offset < 72 {
|
||||
return Err(Error::Malformed("thumbnail overlaps the file header"));
|
||||
}
|
||||
let record = bytes.get(offset..).ok_or(Error::Malformed("thumbnail offset outside file"))?;
|
||||
if bytes_at(record, 0, 8)? != b"\xff\xff\xff\xffThmb" {
|
||||
return Err(Error::Malformed("missing indexed thumbnail"));
|
||||
}
|
||||
if le32(record, 8)? != 1 || bytes_at(record, 28, 1)? != [1] {
|
||||
return Err(Error::Unsupported("thumbnail record version or encoding"));
|
||||
}
|
||||
let size = usize::try_from(le32(record, 24)?).map_err(|_| Error::Limit("encoded size"))?;
|
||||
if size > MAX_PREVIEW_BYTES {
|
||||
return Err(Error::Limit("encoded preview exceeds 16 MiB"));
|
||||
}
|
||||
if le32(record, 16)? != 29 || le32(record, 20)? != 0 || u64::from(le32(record, 12)?) != size as u64 + 13 {
|
||||
return Err(Error::Malformed("inconsistent thumbnail record lengths"));
|
||||
}
|
||||
let png = bytes_at(record, 29, size)?;
|
||||
if bytes_at(png, 0, 8)? != b"\x89PNG\r\n\x1a\n" || be32(png, 8)? != 13 || bytes_at(png, 12, 4)? != b"IHDR" {
|
||||
return Err(Error::Malformed("thumbnail is not a PNG"));
|
||||
}
|
||||
let width = be32(png, 16)?;
|
||||
let height = be32(png, 20)?;
|
||||
if width == 0 || height == 0 || width > MAX_PREVIEW_DIMENSION || height > MAX_PREVIEW_DIMENSION {
|
||||
return Err(Error::Limit("preview dimensions must be 1..4096"));
|
||||
}
|
||||
// Bound work by bytes, not a file-supplied chunk count, and require exactly one complete PNG.
|
||||
let mut pos = 8usize;
|
||||
let (mut palette, mut image_data, mut image_data_ended) = (false, false, false);
|
||||
loop {
|
||||
let length = usize::try_from(be32(png, pos)?).map_err(|_| Error::Limit("PNG chunk length"))?;
|
||||
let chunk_size = length.checked_add(12).ok_or(Error::Malformed("PNG chunk overflow"))?;
|
||||
let chunk = bytes_at(png, pos, chunk_size)?;
|
||||
let checksum_input = bytes_at(chunk, 4, chunk_size - 8)?;
|
||||
if crc32fast::hash(checksum_input) != be32(chunk, chunk_size - 4)? {
|
||||
return Err(Error::Malformed("PNG chunk checksum"));
|
||||
}
|
||||
let kind = bytes_at(chunk, 4, 4)?;
|
||||
if !kind.iter().all(u8::is_ascii_alphabetic) {
|
||||
return Err(Error::Malformed("PNG chunk type"));
|
||||
}
|
||||
match kind {
|
||||
b"IHDR" if pos == 8 => {}
|
||||
b"IHDR" => return Err(Error::Malformed("duplicate PNG header")),
|
||||
b"PLTE" => {
|
||||
if palette || image_data {
|
||||
return Err(Error::Malformed("PNG palette order"));
|
||||
}
|
||||
palette = true;
|
||||
}
|
||||
b"IDAT" => {
|
||||
if image_data_ended {
|
||||
return Err(Error::Malformed("non-contiguous PNG image data"));
|
||||
}
|
||||
image_data = true;
|
||||
}
|
||||
b"IEND" => {
|
||||
if !image_data {
|
||||
return Err(Error::Malformed("PNG has no image data"));
|
||||
}
|
||||
}
|
||||
_ if kind.first().is_some_and(u8::is_ascii_uppercase) => {
|
||||
return Err(Error::Unsupported("unknown critical PNG chunk"));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if image_data && kind != b"IDAT" {
|
||||
image_data_ended = true;
|
||||
}
|
||||
// Native sample previews are plain, single-frame PNGs. Pixel allocation limits do not
|
||||
// necessarily bound aggregate compressed metadata; leave these layouts unsupported.
|
||||
if matches!(kind, b"zTXt" | b"iTXt" | b"iCCP") {
|
||||
return Err(Error::Unsupported("compressed PNG metadata in the preview"));
|
||||
}
|
||||
if matches!(kind, b"acTL" | b"fcTL" | b"fdAT") {
|
||||
return Err(Error::Unsupported("animated PNG preview"));
|
||||
}
|
||||
pos = pos.checked_add(chunk_size).ok_or(Error::Malformed("PNG offset overflow"))?;
|
||||
if kind == b"IEND" {
|
||||
if length != 0 || pos != png.len() {
|
||||
return Err(Error::Malformed("PNG end or trailing data"));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(Preview { png, width, height })
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
//! Pixel layers (`Rstr`) and placed images (`ImgN`). A placed image keeps its original file
|
||||
//! (`DyBm.Bckg` → archive entry `c/N`), which is imported as is; other pixels are planar tiles of
|
||||
//! 256 bytes × 256 rows per channel, each its own archive entry.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::Error;
|
||||
use crate::model::{Affine, Image, Pixels, Reader};
|
||||
use crate::stream::{self, ObjId, Tag, Value};
|
||||
|
||||
/// Pixels decoded per layer (the content's bounding box, not the canvas).
|
||||
const MAX_PIXELS: u64 = 64 << 20;
|
||||
const TILE: usize = 256;
|
||||
const TILE_BYTES: usize = TILE * TILE;
|
||||
|
||||
pub(crate) fn node_image(r: &mut Reader, id: ObjId, world: Affine) -> Result<Option<Image>, Error> {
|
||||
let s = r.s;
|
||||
let Some(bitmap) = s.obj(id, b"Bitm") else {
|
||||
r.warn("pixel layers without pixels");
|
||||
return Ok(None);
|
||||
};
|
||||
if !s.is(bitmap, b"DyBm") {
|
||||
r.warn("pixel layers stored in an unknown way");
|
||||
return Ok(None);
|
||||
}
|
||||
let width = s.int(bitmap, b"BmpW").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
|
||||
let height = s.int(bitmap, b"BmpH").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
|
||||
if width == 0 || height == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
// A placed image: the original file, byte for byte.
|
||||
let has_tiles = s.field(bitmap, b"Sta1").is_some();
|
||||
if let Some(name) = s.entry(bitmap, b"Bckg").filter(|n| !n.is_empty())
|
||||
&& (s.is(id, b"ImgN") || !has_tiles)
|
||||
{
|
||||
match original(r, name) {
|
||||
Ok(bytes) => return Ok(Some(Image { width, height, pixels: Pixels::Encoded(bytes), transform: world })),
|
||||
Err(Error::Limit(e)) => return Err(Error::Limit(e)),
|
||||
Err(_) => r.warn("an embedded image file that could not be read (its cached pixels were used)"),
|
||||
}
|
||||
}
|
||||
// The content's bounding box, so a mostly empty canvas-sized layer stays small.
|
||||
let crop = s
|
||||
.field(id, b"BitR")
|
||||
.and_then(|v| if let Value::Ints(i) = v { Some(i.clone()) } else { None })
|
||||
.filter(|v| v.len() == 4 && v[0] > i64::from(i32::MIN) + 1)
|
||||
.map(|v| {
|
||||
(
|
||||
v[0].clamp(0, i64::from(width)) as u32,
|
||||
v[1].clamp(0, i64::from(height)) as u32,
|
||||
v[2].clamp(0, i64::from(width)) as u32,
|
||||
v[3].clamp(0, i64::from(height)) as u32,
|
||||
)
|
||||
})
|
||||
.unwrap_or((0, 0, width, height));
|
||||
if s.field(bitmap, b"Bckg").is_some() && has_tiles && !s.is(id, b"ImgN") {
|
||||
r.warn("edited placed images use their stored pixels");
|
||||
}
|
||||
decode(r, bitmap, crop, world)
|
||||
}
|
||||
|
||||
/// A mask layer (`MRst`): one channel of coverage as grey pixels (white shows, black hides).
|
||||
pub(crate) fn mask(r: &mut Reader, id: ObjId, world: Affine) -> Result<Option<Image>, Error> {
|
||||
let s = r.s;
|
||||
let Some(bitmap) = s.obj(id, b"Bitm") else { return Ok(None) };
|
||||
let width = s.int(bitmap, b"BmpW").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
|
||||
let height = s.int(bitmap, b"BmpH").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
|
||||
if width == 0 || height == 0 || !matches!(s.enumeration(bitmap, b"Frmt"), Some((6 | 7, _))) {
|
||||
r.warn("pixel masks in an unknown format (imported without them)");
|
||||
return Ok(None);
|
||||
}
|
||||
decode(r, bitmap, (0, 0, width, height), world)
|
||||
}
|
||||
|
||||
/// An embedded document or symbol instance (`EmbN`), as the picture of it Affinity caches.
|
||||
pub(crate) fn embedded(r: &mut Reader, id: ObjId, world: Affine) -> Result<Option<Image>, Error> {
|
||||
let s = r.s;
|
||||
let Some(cache) = s.obj(id, b"Bitm").and_then(|e| s.obj(e, b"Cach")) else { return Ok(None) };
|
||||
let Some(bbox) = s.obj(id, b"Bitm").and_then(|e| s.floats::<4>(e, b"ChBB")) else { return Ok(None) };
|
||||
let width = s.int(cache, b"BmpW").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
|
||||
let height = s.int(cache, b"BmpH").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
|
||||
if width == 0 || height == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let [x0, y0, x1, y1] = bbox;
|
||||
// The instance's origin is the centre of the embedded content (checked against the
|
||||
// thumbnails of documents with embedded documents and SVG files).
|
||||
let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
|
||||
let place = Affine([(x1 - x0) / f64::from(width), 0.0, 0.0, (y1 - y0) / f64::from(height), x0 - cx, y0 - cy]).then(world);
|
||||
decode(r, cache, (0, 0, width, height), place)
|
||||
}
|
||||
|
||||
/// Decode the tiles of a bitmap inside `crop` (pixels), placed by `world` (bitmap pixels to document).
|
||||
fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affine) -> Result<Option<Image>, Error> {
|
||||
let s = r.s;
|
||||
let (x0, y0, x1, y1) = crop;
|
||||
if x1 <= x0 || y1 <= y0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let (w, h) = (x1 - x0, y1 - y0);
|
||||
if u64::from(w) * u64::from(h) > MAX_PIXELS {
|
||||
r.warn("pixel layers larger than 64 megapixels");
|
||||
return Ok(None);
|
||||
}
|
||||
let format = s.enumeration(bitmap, b"Frmt").map(|(f, _)| f);
|
||||
let (channels, bps) = match format {
|
||||
Some(0) => (4, 1),
|
||||
Some(1) => (4, 2),
|
||||
Some(6) => (1, 1),
|
||||
Some(7) => (1, 2),
|
||||
Some(4) => {
|
||||
r.warn("CMYK pixel layers (converted to RGB without the document's colour profile)");
|
||||
(5, 1)
|
||||
}
|
||||
_ => {
|
||||
r.warn("pixel layers in grey, Lab or 32-bit formats");
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
let mut planes = Vec::with_capacity(channels);
|
||||
let mut cache = HashMap::new();
|
||||
for c in 1..=channels {
|
||||
planes.push(plane(r, bitmap, c, bps, (x0, y0, w, h), &mut cache)?);
|
||||
}
|
||||
let n = (w as usize) * (h as usize);
|
||||
let mut rgba = vec![0u8; n * 4];
|
||||
let sample = |p: &Vec<u8>, i: usize| -> f64 {
|
||||
if bps == 2 {
|
||||
f64::from(u16::from_le_bytes([p.get(2 * i).copied().unwrap_or(0), p.get(2 * i + 1).copied().unwrap_or(0)])) / 65535.0
|
||||
} else {
|
||||
f64::from(p.get(i).copied().unwrap_or(0)) / 255.0
|
||||
}
|
||||
};
|
||||
for (i, px) in rgba.chunks_exact_mut(4).enumerate() {
|
||||
let v: Vec<f64> = planes.iter().map(|p| sample(p, i)).collect();
|
||||
let (r2, g, b, a) = if channels == 1 {
|
||||
(v[0], v[0], v[0], 1.0)
|
||||
} else if channels == 5 {
|
||||
let k = 1.0 - v[3];
|
||||
((1.0 - v[0]) * k, (1.0 - v[1]) * k, (1.0 - v[2]) * k, v[4])
|
||||
} else {
|
||||
(v[0], v[1], v[2], v[3])
|
||||
};
|
||||
px.copy_from_slice(&[r2, g, b, a].map(|c| (c.clamp(0.0, 1.0) * 255.0).round() as u8));
|
||||
}
|
||||
let transform = Affine([1.0, 0.0, 0.0, 1.0, f64::from(x0), f64::from(y0)]).then(world);
|
||||
Ok(Some(Image { width: w, height: h, pixels: Pixels::Rgba8(rgba), transform }))
|
||||
}
|
||||
|
||||
/// The original image file: entry `c/N` is a stream whose root (`Blck`) holds it as `Data`.
|
||||
fn original(r: &mut Reader, name: &str) -> Result<Vec<u8>, Error> {
|
||||
let bytes = r.archive.read(name)?;
|
||||
let s = stream::parse(&bytes)?;
|
||||
let root = s.object(s.root).ok_or(Error::Malformed("embedded image"))?;
|
||||
match root.get(Tag::of(b"Data")) {
|
||||
Some(Value::Blob(b)) if !b.is_empty() => Ok(b.clone()),
|
||||
_ => Err(Error::Malformed("embedded image without data")),
|
||||
}
|
||||
}
|
||||
|
||||
fn tag(prefix: &[u8; 3], c: usize) -> [u8; 4] {
|
||||
[prefix[0], prefix[1], prefix[2], b'0' + c as u8]
|
||||
}
|
||||
|
||||
/// One channel of level 0, cropped to (x0, y0, w, h) pixels; `bps` bytes per sample.
|
||||
fn plane(
|
||||
r: &mut Reader,
|
||||
bitmap: ObjId,
|
||||
c: usize,
|
||||
bps: usize,
|
||||
(x0, y0, w, h): (u32, u32, u32, u32),
|
||||
cache: &mut HashMap<String, Vec<u8>>,
|
||||
) -> Result<Vec<u8>, Error> {
|
||||
let s = r.s;
|
||||
let width = s.int(bitmap, b"BmpW").and_then(|v| usize::try_from(v).ok()).unwrap_or(0);
|
||||
let height = s.int(bitmap, b"BmpH").and_then(|v| usize::try_from(v).ok()).unwrap_or(0);
|
||||
let tw = s.int(bitmap, &tag(b"TWi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| (width * bps).div_ceil(TILE));
|
||||
let th = s.int(bitmap, &tag(b"THi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| height.div_ceil(TILE));
|
||||
let states: Vec<u8> = match s.field(bitmap, &tag(b"Sta", c)) {
|
||||
Some(Value::Array(a)) => a.iter().map(|v| if let Value::UInt(u) = v { u8::try_from(*u).unwrap_or(255) } else { 255 }).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let tiles = s.objs(bitmap, &tag(b"Idx", c));
|
||||
let row = (w as usize) * bps;
|
||||
let mut out = vec![0u8; row * h as usize];
|
||||
let (bx0, bx1) = (x0 as usize * bps, (x0 + w) as usize * bps);
|
||||
let (py0, py1) = (y0 as usize, (y0 + h) as usize);
|
||||
let mut next = 0usize;
|
||||
for (t, state) in states.iter().enumerate().take(tw.saturating_mul(th)) {
|
||||
let (ty, tx) = (t / tw.max(1), t % tw.max(1));
|
||||
let fill = match state {
|
||||
0 | 1 => None,
|
||||
2 => Some(0xFF),
|
||||
4 => {
|
||||
let Some(blck) = tiles.get(next).copied() else { return Err(Error::Malformed("missing pixel tile")) };
|
||||
next += 1;
|
||||
let (ox, oy) = (tx * TILE, ty * TILE);
|
||||
if ox >= bx1 || ox + TILE <= bx0 || oy >= py1 || oy + TILE <= py0 {
|
||||
continue;
|
||||
}
|
||||
let name = s.entry(blck, b"Data").ok_or(Error::Malformed("pixel tile without data"))?.to_string();
|
||||
if !cache.contains_key(&name) {
|
||||
let mut data = r.archive.read(&name)?;
|
||||
// Older files wrap a tile in a one-field stream: header, then a 64 KiB blob.
|
||||
if data.len() == TILE_BYTES + 22 && data.starts_with(b"\x00\xffKS") {
|
||||
data = data.get(21..21 + TILE_BYTES).ok_or(Error::Malformed("pixel tile"))?.to_vec();
|
||||
}
|
||||
if data.len() != TILE_BYTES {
|
||||
return Err(Error::Malformed("pixel tile size"));
|
||||
}
|
||||
cache.insert(name.clone(), data);
|
||||
}
|
||||
let tile = cache.get(&name).ok_or(Error::Malformed("pixel tile"))?;
|
||||
copy(&mut out, row, (bx0, py0, bx1, py1), (ox, oy), |x, y| tile.get(y * TILE + x).copied().unwrap_or(0));
|
||||
continue;
|
||||
}
|
||||
3 => {
|
||||
r.warn("32-bit pixel layers");
|
||||
None
|
||||
}
|
||||
5 => {
|
||||
r.warn("pixel layers drawn from an embedded image (left empty)");
|
||||
None
|
||||
}
|
||||
_ => return Err(Error::Malformed("unknown pixel tile state")),
|
||||
};
|
||||
if let Some(v) = fill {
|
||||
copy(&mut out, row, (bx0, py0, bx1, py1), (tx * TILE, ty * TILE), |_, _| v);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Copy the overlap of a tile at byte column `ox`, row `oy` into the cropped plane.
|
||||
fn copy(out: &mut [u8], row: usize, (bx0, py0, bx1, py1): (usize, usize, usize, usize), (ox, oy): (usize, usize), at: impl Fn(usize, usize) -> u8) {
|
||||
for y in oy.max(py0)..(oy + TILE).min(py1) {
|
||||
for x in ox.max(bx0)..(ox + TILE).min(bx1) {
|
||||
if let Some(b) = out.get_mut((y - py0) * row + (x - bx0)) {
|
||||
*b = at(x - ox, y - oy);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
//! Parametric shapes: Affinity stores the shape's parameters and its box, never the outline.
|
||||
//! Formulas were fitted to Affinity's own renders (the document thumbnails) of public files.
|
||||
|
||||
use std::f64::consts::{FRAC_PI_2, PI, TAU};
|
||||
|
||||
use crate::model::{Affine, Path, Point, Reader, SubPath};
|
||||
use crate::stream::{ObjId, Tag, Value};
|
||||
|
||||
/// Handle length of a quarter circle of radius 1.
|
||||
const KAPPA: f64 = 0.552_284_749_830_793_4;
|
||||
/// Vertices of a polygon or star.
|
||||
const MAX_SIDES: i64 = 10_000;
|
||||
|
||||
/// The last value of a tag: a shape changed from one kind to another keeps a stale first block.
|
||||
fn last<'a>(r: &'a Reader, id: ObjId, tag: &[u8; 4]) -> Option<&'a Value> {
|
||||
let t = Tag::of(tag);
|
||||
r.s.object(id)?.fields.iter().rev().find(|(k, _)| *k == t).map(|(_, v)| v)
|
||||
}
|
||||
|
||||
fn num(r: &Reader, id: ObjId, tag: &[u8; 4]) -> Option<f64> {
|
||||
match last(r, id, tag)? {
|
||||
Value::Float(f) if f.is_finite() => Some(*f),
|
||||
Value::UInt(u) => Some(*u as f64),
|
||||
Value::Int(i) => Some(*i as f64),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn sides(r: &Reader, id: ObjId, tag: &[u8; 4], default: f64) -> usize {
|
||||
let n = num(r, id, tag).unwrap_or(default).round();
|
||||
n.clamp(3.0, MAX_SIDES as f64) as usize
|
||||
}
|
||||
|
||||
fn pt(x: f64, y: f64) -> Point {
|
||||
Point { x, y }
|
||||
}
|
||||
|
||||
fn polygon(pts: impl IntoIterator<Item = Point>) -> Option<SubPath> {
|
||||
let mut it = pts.into_iter();
|
||||
let start = it.next()?;
|
||||
let segments = it.map(|p| [p, p, p]).collect();
|
||||
Some(SubPath { start, segments, closed: true })
|
||||
}
|
||||
|
||||
/// Outline of `shape` (a `Shpe` object) in the node box `b` = (x0, y0, x1, y1).
|
||||
pub(crate) fn shape(r: &mut Reader, shape: ObjId, b: [f64; 4], world: Affine) -> Option<Path> {
|
||||
let [x0, y0, x1, y1] = b;
|
||||
let (w, h) = (x1 - x0, y1 - y0);
|
||||
let (cx, cy, rx, ry) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0, w / 2.0, h / 2.0);
|
||||
let on_ellipse = |a: f64, f: f64| pt(cx + f * rx * a.cos(), cy + f * ry * a.sin());
|
||||
let class = r.s.class(shape)?;
|
||||
let sub = match &class.0.to_be_bytes() {
|
||||
b"ShNR" | b"ShRR" => {
|
||||
let rounded = matches!(last(r, shape, b"CTyp"), Some(Value::Array(a)) if !a.is_empty());
|
||||
let radius = match last(r, shape, b"ShCR") {
|
||||
Some(Value::Floats(v)) => v.first().copied().filter(|f| f.is_finite() && *f > 0.0),
|
||||
_ => None,
|
||||
};
|
||||
if let (true, Some(f)) = (rounded, radius) {
|
||||
if let Some(Value::Array(types)) = last(r, shape, b"CTyp")
|
||||
&& types.iter().any(|t| !matches!(t, Value::Enum { id: 0 | 4, .. }))
|
||||
{
|
||||
r.warn("rectangles with straight, concave or other corner types (imported with round corners)");
|
||||
}
|
||||
let (crx, cry) = if r.s.bool(shape, b"AbSz") == Some(true) {
|
||||
// An absolute radius is in document pixels, whatever the node's scale.
|
||||
let [a, b2, c, d, ..] = world.0;
|
||||
let (sx, sy) = ((a * a + b2 * b2).sqrt(), (c * c + d * d).sqrt());
|
||||
if sx > 0.0 && sy > 0.0 { (f / sx, f / sy) } else { (0.0, 0.0) }
|
||||
} else {
|
||||
(f * w.min(h), f * w.min(h))
|
||||
};
|
||||
rounded_rect(b, crx.min(w / 2.0), cry.min(h / 2.0))
|
||||
} else {
|
||||
polygon([pt(x0, y0), pt(x1, y0), pt(x1, y1), pt(x0, y1)])?
|
||||
}
|
||||
}
|
||||
b"ShpE" => ellipse(cx, cy, rx, ry),
|
||||
b"ShPy" => {
|
||||
let n = sides(r, shape, b"Side", 5.0);
|
||||
let a = |i: usize| -FRAC_PI_2 + TAU * i as f64 / n as f64;
|
||||
if r.s.object(shape).is_some_and(|_| matches!(last(r, shape, b"Smth"), Some(Value::Bool(true)))) {
|
||||
// Smoothed: tangent handles of length curv·4/3·tan(π/2n) along the inscribed ellipse.
|
||||
let k = num(r, shape, b"Curv").unwrap_or(0.0) * 4.0 / 3.0 * (PI / (2.0 * n as f64)).tan();
|
||||
let v = |i: usize| on_ellipse(a(i), 1.0);
|
||||
let t = |i: usize| pt(-rx * a(i).sin(), ry * a(i).cos());
|
||||
let segments = (0..n)
|
||||
.map(|i| {
|
||||
let (p, q, tp, tq) = (v(i), v(i + 1), t(i), t(i + 1));
|
||||
[pt(p.x + k * tp.x, p.y + k * tp.y), pt(q.x - k * tq.x, q.y - k * tq.y), q]
|
||||
})
|
||||
.collect();
|
||||
SubPath { start: v(0), segments, closed: true }
|
||||
} else {
|
||||
polygon((0..n).map(|i| on_ellipse(a(i), 1.0)))?
|
||||
}
|
||||
}
|
||||
b"ShSt" => {
|
||||
let n = sides(r, shape, b"Pnts", 5.0);
|
||||
let q = num(r, shape, b"IRad").unwrap_or(0.382).clamp(0.0, 1.0);
|
||||
if [b"CrcI", b"CrcO", b"CrvL", b"CrvR"].iter().any(|t| num(r, shape, t).is_some_and(|v| v.abs() > 1e-9)) {
|
||||
r.warn("stars with rounded or curved points (imported with sharp points)");
|
||||
}
|
||||
polygon((0..2 * n).map(|i| on_ellipse(-FRAC_PI_2 + PI * i as f64 / n as f64, if i % 2 == 0 { 1.0 } else { q })))?
|
||||
}
|
||||
b"ShSS" => {
|
||||
let n = sides(r, shape, b"Side", 5.0);
|
||||
let ri = num(r, shape, b"COut").unwrap_or(0.5).clamp(0.0, 1.0);
|
||||
let step = PI / n as f64;
|
||||
let (l, hw, inner) = (step.cos(), ri * step.sin(), ri * step.cos());
|
||||
polygon((0..n).flat_map(|k| {
|
||||
let phi = -FRAC_PI_2 + step + TAU * k as f64 / n as f64;
|
||||
let (c, s) = (phi.cos(), phi.sin());
|
||||
[(inner, -hw), (l, -hw), (l, hw), (inner, hw)].map(|(u, v)| pt(cx + rx * (u * c - v * s), cy + ry * (u * s + v * c)))
|
||||
}))?
|
||||
}
|
||||
b"ShPi" => {
|
||||
let start = num(r, shape, b"AngS").unwrap_or(0.0);
|
||||
let mut end = num(r, shape, b"AngE").unwrap_or(TAU);
|
||||
while end < start {
|
||||
end += TAU;
|
||||
}
|
||||
let inner = num(r, shape, b"IRad").unwrap_or(0.0).clamp(0.0, 1.0);
|
||||
pie(cx, cy, rx, ry, start, end.min(start + TAU), inner)
|
||||
}
|
||||
b"ShpT" => {
|
||||
let p = num(r, shape, b"Pos ").unwrap_or(0.5);
|
||||
polygon([pt(x0 + p * w, y0), pt(x1, y1), pt(x0, y1)])?
|
||||
}
|
||||
b"ShTz" => {
|
||||
let (l, rr) = (num(r, shape, b"PosL").unwrap_or(0.25), num(r, shape, b"PosR").unwrap_or(0.75));
|
||||
polygon([pt(x0 + l * w, y0), pt(x0 + rr * w, y0), pt(x1, y1), pt(x0, y1)])?
|
||||
}
|
||||
_ => {
|
||||
r.warn("cloud, heart, cog, callout, arrow, tear and other special shapes (imported as their bounding ellipse)");
|
||||
ellipse(cx, cy, rx, ry)
|
||||
}
|
||||
};
|
||||
Some(Path { subpaths: vec![sub] })
|
||||
}
|
||||
|
||||
pub(crate) fn rectangle(x0: f64, y0: f64, x1: f64, y1: f64) -> SubPath {
|
||||
SubPath { start: pt(x0, y0), segments: [pt(x1, y0), pt(x1, y1), pt(x0, y1)].map(|p| [p, p, p]).to_vec(), closed: true }
|
||||
}
|
||||
|
||||
fn ellipse(cx: f64, cy: f64, rx: f64, ry: f64) -> SubPath {
|
||||
let (kx, ky) = (KAPPA * rx, KAPPA * ry);
|
||||
SubPath {
|
||||
start: pt(cx + rx, cy),
|
||||
segments: vec![
|
||||
[pt(cx + rx, cy + ky), pt(cx + kx, cy + ry), pt(cx, cy + ry)],
|
||||
[pt(cx - kx, cy + ry), pt(cx - rx, cy + ky), pt(cx - rx, cy)],
|
||||
[pt(cx - rx, cy - ky), pt(cx - kx, cy - ry), pt(cx, cy - ry)],
|
||||
[pt(cx + kx, cy - ry), pt(cx + rx, cy - ky), pt(cx + rx, cy)],
|
||||
],
|
||||
closed: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn rounded_rect([x0, y0, x1, y1]: [f64; 4], rx: f64, ry: f64) -> SubPath {
|
||||
let (kx, ky) = ((1.0 - KAPPA) * rx, (1.0 - KAPPA) * ry);
|
||||
SubPath {
|
||||
start: pt(x0 + rx, y0),
|
||||
segments: vec![
|
||||
[pt(x1 - rx, y0), pt(x1 - rx, y0), pt(x1 - rx, y0)],
|
||||
[pt(x1 - kx, y0), pt(x1, y0 + ky), pt(x1, y0 + ry)],
|
||||
[pt(x1, y1 - ry), pt(x1, y1 - ry), pt(x1, y1 - ry)],
|
||||
[pt(x1, y1 - ky), pt(x1 - kx, y1), pt(x1 - rx, y1)],
|
||||
[pt(x0 + rx, y1), pt(x0 + rx, y1), pt(x0 + rx, y1)],
|
||||
[pt(x0 + kx, y1), pt(x0, y1 - ky), pt(x0, y1 - ry)],
|
||||
[pt(x0, y0 + ry), pt(x0, y0 + ry), pt(x0, y0 + ry)],
|
||||
[pt(x0, y0 + ky), pt(x0 + kx, y0), pt(x0 + rx, y0)],
|
||||
],
|
||||
closed: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Elliptical arc from angle `a0` to `a1` (radians, measured y-up as Affinity's pie angles are),
|
||||
/// as cubic segments of at most a quarter turn.
|
||||
fn arc(cx: f64, cy: f64, rx: f64, ry: f64, a0: f64, a1: f64, out: &mut Vec<[Point; 3]>) {
|
||||
let n = ((a1 - a0).abs() / FRAC_PI_2).ceil().clamp(1.0, 8.0) as usize;
|
||||
let step = (a1 - a0) / n as f64;
|
||||
let k = 4.0 / 3.0 * (step / 4.0).tan();
|
||||
let p = |a: f64| pt(cx + rx * a.cos(), cy - ry * a.sin());
|
||||
let d = |a: f64| pt(-rx * a.sin(), -ry * a.cos());
|
||||
for i in 0..n {
|
||||
let (s, e) = (a0 + step * i as f64, a0 + step * (i + 1) as f64);
|
||||
let (ps, pe, ds, de) = (p(s), p(e), d(s), d(e));
|
||||
out.push([pt(ps.x + k * ds.x, ps.y + k * ds.y), pt(pe.x - k * de.x, pe.y - k * de.y), pe]);
|
||||
}
|
||||
}
|
||||
|
||||
fn pie(cx: f64, cy: f64, rx: f64, ry: f64, a0: f64, a1: f64, inner: f64) -> SubPath {
|
||||
let start = pt(cx + rx * a0.cos(), cy - ry * a0.sin());
|
||||
let mut segments = Vec::new();
|
||||
arc(cx, cy, rx, ry, a0, a1, &mut segments);
|
||||
if inner > 0.0 {
|
||||
let p = pt(cx + inner * rx * a1.cos(), cy - inner * ry * a1.sin());
|
||||
segments.push([p, p, p]);
|
||||
arc(cx, cy, inner * rx, inner * ry, a1, a0, &mut segments);
|
||||
} else {
|
||||
let c = pt(cx, cy);
|
||||
segments.push([c, c, c]);
|
||||
}
|
||||
SubPath { start, segments, closed: true }
|
||||
}
|
||||
|
||||
/// A polyline with circular corners of the given radii (Affinity's live corners on curves).
|
||||
pub(crate) fn fillet(pts: &[(Point, f64)], closed: bool) -> SubPath {
|
||||
let n = pts.len();
|
||||
let get = |i: isize| -> Option<Point> {
|
||||
if closed { pts.get(i.rem_euclid(n as isize) as usize).map(|p| p.0) } else { usize::try_from(i).ok().and_then(|i| pts.get(i)).map(|p| p.0) }
|
||||
};
|
||||
let mut corners: Vec<(Point, Point, Point, Point)> = Vec::with_capacity(n);
|
||||
for (i, (p, radius)) in pts.iter().enumerate() {
|
||||
let (prev, next) = (get(i as isize - 1), get(i as isize + 1));
|
||||
let (Some(a), Some(b)) = (prev, next) else {
|
||||
corners.push((*p, *p, *p, *p));
|
||||
continue;
|
||||
};
|
||||
let (u1, l1) = unit(*p, a);
|
||||
let (u2, l2) = unit(*p, b);
|
||||
let cos = (u1.x * u2.x + u1.y * u2.y).clamp(-1.0, 1.0);
|
||||
let phi = cos.acos();
|
||||
if *radius <= 0.0 || phi < 1e-6 || (PI - phi) < 1e-6 {
|
||||
corners.push((*p, *p, *p, *p));
|
||||
continue;
|
||||
}
|
||||
let t = (radius / (phi / 2.0).tan()).min(l1 / 2.0).min(l2 / 2.0);
|
||||
let r = t * (phi / 2.0).tan();
|
||||
let k = 4.0 / 3.0 * ((PI - phi) / 4.0).tan() * r;
|
||||
let s = pt(p.x + u1.x * t, p.y + u1.y * t);
|
||||
let e = pt(p.x + u2.x * t, p.y + u2.y * t);
|
||||
corners.push((s, pt(s.x - u1.x * k, s.y - u1.y * k), pt(e.x - u2.x * k, e.y - u2.y * k), e));
|
||||
}
|
||||
let Some(first) = corners.first().copied() else { return SubPath { start: pt(0.0, 0.0), segments: vec![], closed } };
|
||||
let mut segments = Vec::with_capacity(2 * n);
|
||||
let mut cur = first.0;
|
||||
let start = cur;
|
||||
for (i, (s, c1, c2, e)) in corners.iter().enumerate() {
|
||||
if i > 0 {
|
||||
segments.push([cur, *s, *s]);
|
||||
}
|
||||
if s != e {
|
||||
segments.push([*c1, *c2, *e]);
|
||||
}
|
||||
cur = *e;
|
||||
}
|
||||
if closed && cur != start {
|
||||
segments.push([cur, start, start]);
|
||||
}
|
||||
SubPath { start, segments, closed }
|
||||
}
|
||||
|
||||
fn unit(from: Point, to: Point) -> (Point, f64) {
|
||||
let (dx, dy) = (to.x - from.x, to.y - from.y);
|
||||
let l = (dx * dx + dy * dy).sqrt();
|
||||
if l > 0.0 { (pt(dx / l, dy / l), l) } else { (pt(0.0, 0.0), 0.0) }
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
//! The tagged object stream inside `doc.dat`: a self-describing tree of objects whose fields carry
|
||||
//! a type code and a four-character tag. Parsing needs no schema; unknown fields are kept, so the
|
||||
//! document mapping can name what it could not import.
|
||||
|
||||
use crate::Error;
|
||||
use crate::container::Cursor;
|
||||
|
||||
/// A four-character tag in its readable order (`Desc`, `Chld`).
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct Tag(pub u32);
|
||||
|
||||
impl Tag {
|
||||
pub const fn of(s: &[u8; 4]) -> Self {
|
||||
Self(u32::from_be_bytes(*s))
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Tag {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", String::from_utf8_lossy(&self.0.to_be_bytes()))
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Tag {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
std::fmt::Debug::fmt(self, f)
|
||||
}
|
||||
}
|
||||
|
||||
/// Index of an object in [`Stream::objects`].
|
||||
pub type ObjId = usize;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Value {
|
||||
Int(i64),
|
||||
UInt(u64),
|
||||
Float(f64),
|
||||
Bool(bool),
|
||||
Enum {
|
||||
id: u16,
|
||||
version: u16,
|
||||
},
|
||||
Str(String),
|
||||
/// Fixed-length vectors of i32/f32/f64 (points, rectangles, transforms).
|
||||
Ints(Vec<i64>),
|
||||
Floats(Vec<f64>),
|
||||
/// Fixed-size record (curve nodes, ids) or fixed-size struct (colour components).
|
||||
Bytes(Vec<u8>),
|
||||
Blob(Vec<u8>),
|
||||
/// Name of another archive entry (raster tiles, embedded files).
|
||||
Entry(String),
|
||||
Flags(u64),
|
||||
/// `None` is a null object.
|
||||
Obj(Option<ObjId>),
|
||||
Array(Vec<Value>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Kind {
|
||||
Root,
|
||||
/// Defined once and referenced by id elsewhere.
|
||||
Shared,
|
||||
Inline,
|
||||
/// Untagged fields identified by position (curve data).
|
||||
Positional,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Object {
|
||||
pub class: Tag,
|
||||
pub kind: Kind,
|
||||
/// Class chain, most derived first (`ShpN`, `VNod`, `Node`).
|
||||
pub chain: Vec<Tag>,
|
||||
/// Fields in stream order; positional fields have tag 0.
|
||||
pub fields: Vec<(Tag, Value)>,
|
||||
}
|
||||
|
||||
impl Object {
|
||||
pub fn get(&self, tag: Tag) -> Option<&Value> {
|
||||
self.fields.iter().find(|(t, _)| *t == tag).map(|(_, v)| v)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Stream {
|
||||
pub root: ObjId,
|
||||
pub objects: Vec<Object>,
|
||||
}
|
||||
|
||||
/// Deepest object nesting accepted; real documents stay well below (each layer level adds three).
|
||||
pub const MAX_DEPTH: usize = 384;
|
||||
|
||||
const MAX_OBJECTS: usize = 8_000_000;
|
||||
|
||||
/// Most values (fields and array elements) one stream may decode. A value takes about 40 bytes
|
||||
/// in memory however few it took in the file (a bool array packs eight in a byte), so the size
|
||||
/// of the input alone doesn't bound what parsing allocates: a small compressed entry could
|
||||
/// otherwise ask for tens of gigabytes. Real documents stay far below.
|
||||
pub const MAX_VALUES: usize = 1 << 24;
|
||||
|
||||
struct Parser<'a> {
|
||||
c: Cursor<'a>,
|
||||
objects: Vec<Object>,
|
||||
/// Shared ids, defined at first use and referenced backwards.
|
||||
shared: std::collections::HashMap<u32, ObjId>,
|
||||
depth: usize,
|
||||
/// Values decoded so far, against [`MAX_VALUES`].
|
||||
values: usize,
|
||||
}
|
||||
|
||||
/// Parse a stream (`00 FF 4B 53` header). Bytes after the root's terminator are ignored.
|
||||
pub fn parse(bytes: &[u8]) -> Result<Stream, Error> {
|
||||
let mut c = Cursor::new(bytes, 0);
|
||||
if c.take(4)? != b"\x00\xffKS" {
|
||||
return Err(Error::Malformed("document stream signature"));
|
||||
}
|
||||
let version = c.u16()?;
|
||||
if version > 2 {
|
||||
return Err(Error::Unsupported("document stream version"));
|
||||
}
|
||||
let class = Tag(c.u32()?);
|
||||
c.u16()?;
|
||||
if version == 2 {
|
||||
c.u32()?;
|
||||
}
|
||||
let mut p = Parser { c, objects: Vec::new(), shared: std::collections::HashMap::new(), depth: 0, values: 0 };
|
||||
let root = p.push(Object { class, kind: Kind::Root, chain: vec![class], fields: Vec::new() })?;
|
||||
let fields = p.fields(true, class)?;
|
||||
if let Some(o) = p.objects.get_mut(root) {
|
||||
o.fields = fields;
|
||||
}
|
||||
Ok(Stream { root, objects: p.objects })
|
||||
}
|
||||
|
||||
impl Parser<'_> {
|
||||
fn push(&mut self, o: Object) -> Result<ObjId, Error> {
|
||||
if self.objects.len() >= MAX_OBJECTS {
|
||||
return Err(Error::Limit("too many objects"));
|
||||
}
|
||||
self.objects.push(o);
|
||||
Ok(self.objects.len() - 1)
|
||||
}
|
||||
|
||||
/// Counts `n` more values against [`MAX_VALUES`], before anything is allocated for them.
|
||||
fn spend(&mut self, n: usize) -> Result<(), Error> {
|
||||
self.values = self.values.checked_add(n).filter(|&v| v <= MAX_VALUES).ok_or(Error::Limit("too many values in the document stream"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// An empty array with room for `n` values, paid for first; an allocation that still fails
|
||||
/// is an error, not an abort.
|
||||
fn vec<T>(&mut self, n: usize) -> Result<Vec<T>, Error> {
|
||||
self.spend(n)?;
|
||||
let mut v = Vec::new();
|
||||
v.try_reserve_exact(n).map_err(|_| Error::Limit("not enough memory for the document stream"))?;
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// Element count of an array: each element takes at least `min` bytes, so a count the
|
||||
/// remaining input cannot hold is rejected before anything is allocated.
|
||||
fn count(&mut self, min: usize) -> Result<usize, Error> {
|
||||
let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("array length"))?;
|
||||
if n.checked_mul(min.max(1)).is_none_or(|bytes| bytes > self.c.remaining()) {
|
||||
return Err(Error::Malformed("array longer than its data"));
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
fn fields(&mut self, tagged: bool, class: Tag) -> Result<Vec<(Tag, Value)>, Error> {
|
||||
self.depth += 1;
|
||||
if self.depth > MAX_DEPTH {
|
||||
return Err(Error::Limit("objects nested too deeply"));
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
loop {
|
||||
let t = self.c.u8()?;
|
||||
let code = t & 0x7F;
|
||||
if code == 0 {
|
||||
break;
|
||||
}
|
||||
self.spend(1)?;
|
||||
let tag = if tagged { Tag(self.c.u32()?) } else { Tag(0) };
|
||||
let v = if t & 0x80 != 0 { self.array(code, class)? } else { self.scalar(code, tagged, class)? };
|
||||
out.push((tag, v));
|
||||
}
|
||||
self.depth -= 1;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn string(&mut self) -> Result<String, Error> {
|
||||
let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("string length"))?;
|
||||
Ok(String::from_utf8_lossy(self.c.take(n)?).into_owned())
|
||||
}
|
||||
|
||||
fn floats(&mut self, n: usize, wide: bool) -> Result<Value, Error> {
|
||||
let mut v = Vec::with_capacity(n);
|
||||
for _ in 0..n {
|
||||
v.push(if wide { f64::from_le_bytes(self.c.array()?) } else { f64::from(f32::from_le_bytes(self.c.array()?)) });
|
||||
}
|
||||
Ok(Value::Floats(v))
|
||||
}
|
||||
|
||||
fn scalar(&mut self, code: u8, tagged: bool, class: Tag) -> Result<Value, Error> {
|
||||
let c = &mut self.c;
|
||||
Ok(match code {
|
||||
0x01 => Value::UInt(c.u8()?.into()),
|
||||
0x02 => Value::UInt(c.u16()?.into()),
|
||||
0x03 | 0x2f | 0x34 => Value::UInt(c.u32()?.into()),
|
||||
0x04 => Value::UInt(c.u64()?),
|
||||
0x05 => Value::Int(i8::from_le_bytes(c.array()?).into()),
|
||||
0x06 => Value::Int(i16::from_le_bytes(c.array()?).into()),
|
||||
0x07 => Value::Int(i32::from_le_bytes(c.array()?).into()),
|
||||
0x08 => Value::Int(i64::from_le_bytes(c.array()?)),
|
||||
0x09 => Value::Float(f32::from_le_bytes(c.array()?).into()),
|
||||
0x0a => Value::Float(f64::from_le_bytes(c.array()?)),
|
||||
0x15..=0x19 => {
|
||||
let n = usize::from(code - 0x13);
|
||||
let mut v = Vec::with_capacity(n);
|
||||
for _ in 0..n {
|
||||
v.push(i32::from_le_bytes(c.array()?).into());
|
||||
}
|
||||
Value::Ints(v)
|
||||
}
|
||||
0x1f..=0x23 => return self.floats(usize::from(code - 0x1d), false),
|
||||
0x24..=0x28 => return self.floats(usize::from(code - 0x22), true),
|
||||
0x29 => Value::Bool(c.u8()? != 0),
|
||||
0x2a => Value::Enum { id: c.u16()?, version: c.u16()? },
|
||||
0x2b | 0x2e => Value::Str(self.string()?),
|
||||
0x2c => {
|
||||
let n = usize::from(c.u16()?);
|
||||
Value::Bytes(c.take(n)?.to_vec())
|
||||
}
|
||||
0x35..=0x74 => Value::Bytes(c.take(usize::from(code - 0x34))?.to_vec()),
|
||||
0x2d => {
|
||||
let n = usize::try_from(c.u32()?).map_err(|_| Error::Malformed("blob length"))?;
|
||||
Value::Blob(c.take(n)?.to_vec())
|
||||
}
|
||||
0x33 => {
|
||||
c.u32()?;
|
||||
let n = usize::try_from(c.u32()?).map_err(|_| Error::Malformed("entry name length"))?;
|
||||
Value::Entry(String::from_utf8_lossy(c.take(n)?).into_owned())
|
||||
}
|
||||
0x75 => {
|
||||
c.u16()?;
|
||||
let n = usize::from(c.u8()?);
|
||||
if n > 8 {
|
||||
return Err(Error::Malformed("flags wider than 64 bits"));
|
||||
}
|
||||
let mut b = [0u8; 8];
|
||||
b.get_mut(..n).ok_or(Error::Malformed("flags"))?.copy_from_slice(c.take(n)?);
|
||||
Value::Flags(u64::from_le_bytes(b))
|
||||
}
|
||||
0x30 => {
|
||||
let fields = self.fields(false, class)?;
|
||||
let id = self.push(Object { class, kind: Kind::Positional, chain: Vec::new(), fields })?;
|
||||
Value::Obj(Some(id))
|
||||
}
|
||||
0x31 | 0x32 if !tagged => return Err(Error::Malformed("object without a field tag")),
|
||||
0x31 => self.shared()?,
|
||||
0x32 => self.inline(None)?,
|
||||
_ => return Err(Error::Unsupported("unknown field type in document stream")),
|
||||
})
|
||||
}
|
||||
|
||||
fn array(&mut self, code: u8, class: Tag) -> Result<Value, Error> {
|
||||
let min = match code {
|
||||
0x01 | 0x05 | 0x29 => 1,
|
||||
0x02 | 0x06 => 2,
|
||||
0x03 | 0x07 | 0x09 | 0x2f | 0x34 => 4,
|
||||
0x04 | 0x08 | 0x0a => 8,
|
||||
0x15..=0x19 => 4 * usize::from(code - 0x13),
|
||||
0x1f..=0x23 => 4 * usize::from(code - 0x1d),
|
||||
0x24..=0x28 => 8 * usize::from(code - 0x22),
|
||||
0x35..=0x74 => usize::from(code - 0x34),
|
||||
_ => 1,
|
||||
};
|
||||
match code {
|
||||
0x29 => {
|
||||
let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("array length"))?;
|
||||
let bits = self.c.take(n.div_ceil(8))?;
|
||||
let mut v = self.vec(n)?;
|
||||
v.extend((0..n).map(|i| Value::Bool(bits.get(i / 8).is_some_and(|b| b >> (i % 8) & 1 != 0))));
|
||||
Ok(Value::Array(v))
|
||||
}
|
||||
0x2a => {
|
||||
let n = self.count(2)?;
|
||||
let version = self.c.u16()?;
|
||||
let mut v = self.vec(n)?;
|
||||
for _ in 0..n {
|
||||
v.push(Value::Enum { id: self.c.u16()?, version });
|
||||
}
|
||||
Ok(Value::Array(v))
|
||||
}
|
||||
0x2b | 0x2e => {
|
||||
self.c.u32()?;
|
||||
let n = self.count(4)?;
|
||||
let mut v = self.vec(n)?;
|
||||
for _ in 0..n {
|
||||
v.push(Value::Str(self.string()?));
|
||||
}
|
||||
Ok(Value::Array(v))
|
||||
}
|
||||
0x2c => {
|
||||
let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("array length"))?;
|
||||
let size = usize::from(self.c.u16()?);
|
||||
if n.checked_mul(size).is_none_or(|b| b > self.c.remaining()) {
|
||||
return Err(Error::Malformed("array longer than its data"));
|
||||
}
|
||||
let mut v = self.vec(n)?;
|
||||
for _ in 0..n {
|
||||
v.push(Value::Bytes(self.c.take(size)?.to_vec()));
|
||||
}
|
||||
Ok(Value::Array(v))
|
||||
}
|
||||
0x32 => {
|
||||
let n = self.count(1)?;
|
||||
let class = Tag(self.c.u32()?);
|
||||
self.c.u16()?;
|
||||
let mut v = self.vec(n)?;
|
||||
for _ in 0..n {
|
||||
v.push(self.inline(Some(class))?);
|
||||
}
|
||||
Ok(Value::Array(v))
|
||||
}
|
||||
0x2d | 0x33 | 0x75 => Err(Error::Malformed("array of an unarrayable type")),
|
||||
_ => {
|
||||
let n = self.count(min)?;
|
||||
let mut v = self.vec(n)?;
|
||||
for _ in 0..n {
|
||||
v.push(self.scalar(code, true, class)?);
|
||||
}
|
||||
Ok(Value::Array(v))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inline object; `class` is given once for a whole array of them.
|
||||
fn inline(&mut self, class: Option<Tag>) -> Result<Value, Error> {
|
||||
match self.c.u8()? {
|
||||
0 => return Ok(Value::Obj(None)),
|
||||
1 => {}
|
||||
_ => return Err(Error::Malformed("object marker")),
|
||||
}
|
||||
let class = match class {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
let c = Tag(self.c.u32()?);
|
||||
self.c.u16()?;
|
||||
c
|
||||
}
|
||||
};
|
||||
let fields = self.fields(true, class)?;
|
||||
let id = self.push(Object { class, kind: Kind::Inline, chain: vec![class], fields })?;
|
||||
Ok(Value::Obj(Some(id)))
|
||||
}
|
||||
|
||||
fn shared(&mut self) -> Result<Value, Error> {
|
||||
let flag = self.c.u8()?;
|
||||
if flag == 0 {
|
||||
return Ok(Value::Obj(None));
|
||||
}
|
||||
let key = self.c.u32()?;
|
||||
match flag {
|
||||
2 => return self.shared.get(&key).map(|id| Value::Obj(Some(*id))).ok_or(Error::Malformed("reference to an undefined object")),
|
||||
1 => {}
|
||||
_ => return Err(Error::Malformed("object marker")),
|
||||
}
|
||||
if self.shared.contains_key(&key) {
|
||||
return Err(Error::Malformed("object defined twice"));
|
||||
}
|
||||
// Register before the fields: an object's own fields may refer back to it.
|
||||
let id = self.push(Object { class: Tag(0), kind: Kind::Shared, chain: Vec::new(), fields: Vec::new() })?;
|
||||
self.shared.insert(key, id);
|
||||
let mut chain = Vec::new();
|
||||
let mut fields = Vec::new();
|
||||
loop {
|
||||
match self.c.u8()? {
|
||||
0 => {
|
||||
let tag = Tag(self.c.u32()?);
|
||||
self.c.u16()?;
|
||||
chain.push(tag);
|
||||
fields.extend(self.fields(true, tag)?);
|
||||
}
|
||||
1 => {
|
||||
chain.push(Tag(self.c.u32()?));
|
||||
break;
|
||||
}
|
||||
2 => break,
|
||||
_ => return Err(Error::Malformed("object class chain")),
|
||||
}
|
||||
if chain.len() > 64 {
|
||||
return Err(Error::Limit("object class chain too long"));
|
||||
}
|
||||
}
|
||||
let class = chain.first().copied().unwrap_or(Tag(0));
|
||||
fields.extend(self.fields(true, class)?);
|
||||
if let Some(o) = self.objects.get_mut(id) {
|
||||
*o = Object { class, kind: Kind::Shared, chain, fields };
|
||||
}
|
||||
Ok(Value::Obj(Some(id)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
//! Synthetic containers for tests and fuzz seeds (cargo feature `synth`).
|
||||
//!
|
||||
//! These builders write the layout the reader accepts, so tests can describe documents in a few
|
||||
//! lines. They are **not** an Affinity exporter: nothing written here has been opened in Affinity.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use crate::stream::Tag;
|
||||
|
||||
/// A field value to encode.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum F {
|
||||
U8(u8),
|
||||
U32(u32),
|
||||
I32(i32),
|
||||
F32(f32),
|
||||
F64(f64),
|
||||
Bool(bool),
|
||||
Enum(u16, u16),
|
||||
Str(String),
|
||||
/// f64 vector of 2..6 values (points, rectangles, transforms).
|
||||
F64s(Vec<f64>),
|
||||
/// Fixed-size struct (colour components), 1..64 bytes.
|
||||
Struct(Vec<u8>),
|
||||
/// Array of fixed-size records, all `size` bytes.
|
||||
Records(u16, Vec<Vec<u8>>),
|
||||
U8s(Vec<u8>),
|
||||
Entry(String),
|
||||
/// Shared object: `Def` defines id with class chain and fields, `Ref` links back.
|
||||
Def(u32, Vec<Tag>, Vec<(Tag, F)>),
|
||||
Ref(u32),
|
||||
Null,
|
||||
/// Inline object.
|
||||
Obj(Tag, Vec<(Tag, F)>),
|
||||
/// Positional (untagged) object.
|
||||
Pos(Vec<F>),
|
||||
/// Array of shared objects / links.
|
||||
Shared(Vec<F>),
|
||||
/// Array of inline objects of one class.
|
||||
Objs(Tag, Vec<Vec<(Tag, F)>>),
|
||||
/// Raw bytes appended verbatim (malformed-input tests).
|
||||
Raw(Vec<u8>),
|
||||
}
|
||||
|
||||
pub fn tag(s: &[u8; 4]) -> Tag {
|
||||
Tag::of(s)
|
||||
}
|
||||
|
||||
fn put_tag(out: &mut Vec<u8>, t: Tag) {
|
||||
out.extend(t.0.to_le_bytes());
|
||||
}
|
||||
|
||||
fn code(f: &F) -> u8 {
|
||||
match f {
|
||||
F::U8(_) => 0x01,
|
||||
F::U32(_) => 0x03,
|
||||
F::I32(_) => 0x07,
|
||||
F::F32(_) => 0x09,
|
||||
F::F64(_) => 0x0a,
|
||||
F::Bool(_) => 0x29,
|
||||
F::Enum(..) => 0x2a,
|
||||
F::Str(_) => 0x2b,
|
||||
F::F64s(v) => 0x22 + v.len() as u8,
|
||||
F::Struct(b) => 0x34 + b.len() as u8,
|
||||
F::Records(..) => 0x80 | 0x2c,
|
||||
F::U8s(_) => 0x80 | 0x01,
|
||||
F::Entry(_) => 0x33,
|
||||
F::Def(..) | F::Ref(_) => 0x31,
|
||||
F::Null => 0x31,
|
||||
F::Obj(..) => 0x32,
|
||||
F::Pos(_) => 0x30,
|
||||
F::Shared(_) => 0x80 | 0x31,
|
||||
F::Objs(..) => 0x80 | 0x32,
|
||||
F::Raw(_) => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn payload(out: &mut Vec<u8>, f: &F) {
|
||||
match f {
|
||||
F::U8(v) => out.push(*v),
|
||||
F::U32(v) => out.extend(v.to_le_bytes()),
|
||||
F::I32(v) => out.extend(v.to_le_bytes()),
|
||||
F::F32(v) => out.extend(v.to_le_bytes()),
|
||||
F::F64(v) => out.extend(v.to_le_bytes()),
|
||||
F::Bool(v) => out.push(u8::from(*v)),
|
||||
F::Enum(id, ver) => {
|
||||
out.extend(id.to_le_bytes());
|
||||
out.extend(ver.to_le_bytes());
|
||||
}
|
||||
F::Str(s) => {
|
||||
out.extend((s.len() as u32).to_le_bytes());
|
||||
out.extend(s.as_bytes());
|
||||
}
|
||||
F::F64s(v) => v.iter().for_each(|x| out.extend(x.to_le_bytes())),
|
||||
F::Struct(b) => out.extend(b),
|
||||
F::Records(size, recs) => {
|
||||
out.extend((recs.len() as u32).to_le_bytes());
|
||||
out.extend(size.to_le_bytes());
|
||||
recs.iter().for_each(|r| out.extend(r));
|
||||
}
|
||||
F::U8s(v) => {
|
||||
out.extend((v.len() as u32).to_le_bytes());
|
||||
out.extend(v);
|
||||
}
|
||||
F::Entry(name) => {
|
||||
put_tag(out, tag(b"Data"));
|
||||
out.extend((name.len() as u32).to_le_bytes());
|
||||
out.extend(name.as_bytes());
|
||||
}
|
||||
F::Def(id, chain, fields) => {
|
||||
out.push(1);
|
||||
out.extend(id.to_le_bytes());
|
||||
// Class chain: each level but the last carries no fields here; the last ends the chain.
|
||||
let (last, rest) = chain.split_last().expect("class chain");
|
||||
for t in rest {
|
||||
out.push(0);
|
||||
put_tag(out, *t);
|
||||
out.extend(0u16.to_le_bytes());
|
||||
out.push(0);
|
||||
}
|
||||
out.push(1);
|
||||
put_tag(out, *last);
|
||||
fields_into(out, fields, true);
|
||||
}
|
||||
F::Ref(id) => {
|
||||
out.push(2);
|
||||
out.extend(id.to_le_bytes());
|
||||
}
|
||||
F::Null => out.push(0),
|
||||
F::Obj(class, fields) => {
|
||||
out.push(1);
|
||||
put_tag(out, *class);
|
||||
out.extend(0u16.to_le_bytes());
|
||||
fields_into(out, fields, true);
|
||||
}
|
||||
F::Pos(items) => {
|
||||
for f in items {
|
||||
out.push(code(f));
|
||||
payload(out, f);
|
||||
}
|
||||
out.push(0);
|
||||
}
|
||||
F::Shared(items) => {
|
||||
out.extend((items.len() as u32).to_le_bytes());
|
||||
items.iter().for_each(|f| payload(out, f));
|
||||
}
|
||||
F::Objs(class, items) => {
|
||||
out.extend((items.len() as u32).to_le_bytes());
|
||||
put_tag(out, *class);
|
||||
out.extend(0u16.to_le_bytes());
|
||||
for fields in items {
|
||||
out.push(1);
|
||||
fields_into(out, fields, true);
|
||||
}
|
||||
}
|
||||
F::Raw(b) => out.extend(b),
|
||||
}
|
||||
}
|
||||
|
||||
fn fields_into(out: &mut Vec<u8>, fields: &[(Tag, F)], tagged: bool) {
|
||||
for (t, f) in fields {
|
||||
if let F::Raw(b) = f {
|
||||
out.extend(b);
|
||||
continue;
|
||||
}
|
||||
out.push(code(f));
|
||||
if tagged {
|
||||
put_tag(out, *t);
|
||||
}
|
||||
payload(out, f);
|
||||
}
|
||||
out.push(0);
|
||||
}
|
||||
|
||||
/// A `doc.dat` stream with root class `Pers`.
|
||||
pub fn stream(root: &[(Tag, F)]) -> Vec<u8> {
|
||||
let mut out = b"\x00\xffKS".to_vec();
|
||||
out.extend(2u16.to_le_bytes());
|
||||
put_tag(&mut out, tag(b"Pers"));
|
||||
out.extend(0u16.to_le_bytes());
|
||||
out.extend(30u32.to_le_bytes());
|
||||
fields_into(&mut out, root, true);
|
||||
out
|
||||
}
|
||||
|
||||
/// How an entry is stored.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Method {
|
||||
Stored,
|
||||
Zlib,
|
||||
Zstd,
|
||||
}
|
||||
|
||||
/// A version-12 container with one `#FT4` table holding `entries`, then an optional thumbnail record.
|
||||
pub fn container(entries: &[(&str, &[u8], Method)], thumbnail_png: Option<&[u8]>) -> Vec<u8> {
|
||||
let mut out = vec![0u8; 72];
|
||||
out[..4].copy_from_slice(crate::container::MAGIC);
|
||||
out[4..6].copy_from_slice(&12u16.to_le_bytes());
|
||||
out[6..8].copy_from_slice(&4u16.to_le_bytes());
|
||||
out[8..12].copy_from_slice(b"nsrP");
|
||||
out[12..16].copy_from_slice(b"#Inf");
|
||||
out[64..68].copy_from_slice(b"Prot");
|
||||
let mut records = Vec::new();
|
||||
for (i, (name, data, method)) in entries.iter().enumerate() {
|
||||
let packed = match method {
|
||||
Method::Stored => data.to_vec(),
|
||||
Method::Zlib => {
|
||||
use std::io::Write;
|
||||
let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast());
|
||||
e.write_all(data).unwrap();
|
||||
e.finish().unwrap()
|
||||
}
|
||||
Method::Zstd => ruzstd::encoding::compress_to_vec(*data, ruzstd::encoding::CompressionLevel::Fastest),
|
||||
};
|
||||
let offset = out.len() as u64;
|
||||
out.extend(b"#Fil");
|
||||
out.extend(&packed);
|
||||
let mut r = Vec::new();
|
||||
r.extend((i as u32 + 1).to_le_bytes());
|
||||
r.push(0);
|
||||
r.extend(offset.to_le_bytes());
|
||||
r.extend((data.len() as u64).to_le_bytes());
|
||||
r.extend((packed.len() as u64).to_le_bytes());
|
||||
r.extend(crc32fast::hash(data).to_le_bytes());
|
||||
r.push(match method {
|
||||
Method::Stored => 0,
|
||||
Method::Zlib => 1,
|
||||
Method::Zstd => 2,
|
||||
});
|
||||
r.extend(0u32.to_le_bytes());
|
||||
r.extend(0u32.to_le_bytes());
|
||||
r.extend((name.len() as u16).to_le_bytes());
|
||||
r.extend(name.as_bytes());
|
||||
records.push(r);
|
||||
}
|
||||
let fat = out.len() as u64;
|
||||
out.extend(b"#FT4");
|
||||
out.extend(0u64.to_le_bytes());
|
||||
out.extend(1_700_000_000u64.to_le_bytes());
|
||||
out.extend([0u8; 24]);
|
||||
out.extend((records.len() as u32).to_le_bytes());
|
||||
out.extend([0u8; 8]);
|
||||
out.extend(0u16.to_le_bytes());
|
||||
out.push(0);
|
||||
records.iter().for_each(|r| out.extend(r));
|
||||
let thumb = out.len() as u64;
|
||||
out[16..24].copy_from_slice(&fat.to_le_bytes());
|
||||
if let Some(png) = thumbnail_png {
|
||||
out[24..32].copy_from_slice(&thumb.to_le_bytes());
|
||||
out.extend(b"\xff\xff\xff\xffThmb");
|
||||
out.extend(1u32.to_le_bytes());
|
||||
out.extend((png.len() as u32 + 13).to_le_bytes());
|
||||
out.extend(29u32.to_le_bytes());
|
||||
out.extend(0u32.to_le_bytes());
|
||||
out.extend((png.len() as u32).to_le_bytes());
|
||||
out.push(1);
|
||||
out.extend(png);
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
//! Unit tests on synthetic inputs; `tests/real_files.rs` covers public Affinity documents.
|
||||
|
||||
mod preview {
|
||||
use crate::container::header;
|
||||
use crate::*;
|
||||
use proptest::prelude::*;
|
||||
|
||||
fn fixture() -> Vec<u8> {
|
||||
let mut png = b"\x89PNG\r\n\x1a\n".to_vec();
|
||||
png.extend(13u32.to_be_bytes());
|
||||
png.extend(b"IHDR");
|
||||
png.extend(2u32.to_be_bytes());
|
||||
png.extend(1u32.to_be_bytes());
|
||||
png.extend([8, 6, 0, 0, 0]);
|
||||
png.extend(crc32fast::hash(&png[12..]).to_be_bytes());
|
||||
png.extend([0; 4]);
|
||||
png.extend(b"IDAT");
|
||||
png.extend(crc32fast::hash(b"IDAT").to_be_bytes());
|
||||
png.extend([0; 4]);
|
||||
png.extend(b"IEND");
|
||||
png.extend(crc32fast::hash(b"IEND").to_be_bytes());
|
||||
let mut b = vec![0; 72];
|
||||
b[..4].copy_from_slice(MAGIC);
|
||||
b[4..6].copy_from_slice(&12u16.to_le_bytes());
|
||||
b[8..12].copy_from_slice(b"nsrP");
|
||||
b[12..16].copy_from_slice(b"#Inf");
|
||||
b[24..32].copy_from_slice(&72u64.to_le_bytes());
|
||||
b[64..68].copy_from_slice(b"Prot");
|
||||
b.extend(b"\xff\xff\xff\xffThmb");
|
||||
b.extend(1u32.to_le_bytes());
|
||||
b.extend((png.len() as u32 + 13).to_le_bytes());
|
||||
b.extend(29u32.to_le_bytes());
|
||||
b.extend(0u32.to_le_bytes());
|
||||
b.extend((png.len() as u32).to_le_bytes());
|
||||
b.push(1);
|
||||
b.extend(png);
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_preview_and_header() {
|
||||
let b = fixture();
|
||||
assert_eq!(header(&b).unwrap().version, 12);
|
||||
let p = preview(&b).unwrap();
|
||||
assert_eq!((p.width, p.height), (2, 1));
|
||||
assert_eq!(p.png, &b[101..]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_truncation_is_rejected() {
|
||||
let b = fixture();
|
||||
for end in 0..b.len() {
|
||||
assert!(preview(&b[..end]).is_err(), "{end}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_assets_versions_offsets_lengths_and_bombs() {
|
||||
let b = fixture();
|
||||
for (offset, value) in [
|
||||
(4, 13u64.to_le_bytes().to_vec()),
|
||||
(8, b"urBR".to_vec()),
|
||||
(24, u64::MAX.to_le_bytes().to_vec()),
|
||||
(24, 1u64.to_le_bytes().to_vec()),
|
||||
(24, 0u64.to_le_bytes().to_vec()),
|
||||
(84, u32::MAX.to_le_bytes().to_vec()),
|
||||
(88, 30u32.to_le_bytes().to_vec()),
|
||||
(96, u32::MAX.to_le_bytes().to_vec()),
|
||||
(117, 5000u32.to_be_bytes().to_vec()),
|
||||
] {
|
||||
let mut broken = b.clone();
|
||||
broken[offset..offset + value.len()].copy_from_slice(&value);
|
||||
assert!(preview(&broken).is_err(), "offset {offset}");
|
||||
}
|
||||
// Versions 8 to 11 store the same record (checked on public Affinity 1 and 2 files).
|
||||
for version in 8..12 {
|
||||
let mut legacy = b.clone();
|
||||
legacy[4..6].copy_from_slice(&(version as u16).to_le_bytes());
|
||||
assert!(preview(&legacy).is_ok());
|
||||
}
|
||||
// Flags 4 and 8 occur in real files; the low two bits mark other container variants.
|
||||
for (flags, ok) in [(4u16, true), (8, true), (1, false), (2, false)] {
|
||||
let mut variant = b.clone();
|
||||
variant[6..8].copy_from_slice(&flags.to_le_bytes());
|
||||
assert_eq!(preview(&variant).is_ok(), ok, "flags {flags}");
|
||||
}
|
||||
let mut old = b.clone();
|
||||
old[4..6].copy_from_slice(&7u16.to_le_bytes());
|
||||
assert!(matches!(preview(&old), Err(Error::Unsupported(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unindexed_png_or_resource_is_never_used() {
|
||||
let mut b = fixture();
|
||||
b[24..32].copy_from_slice(&0u64.to_le_bytes());
|
||||
assert!(preview(&b).is_err());
|
||||
b[24..32].copy_from_slice(&72u64.to_le_bytes());
|
||||
b[76..80].copy_from_slice(b"Meta");
|
||||
assert!(preview(&b).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_chunk_checksum_including_the_end_is_checked() {
|
||||
let b = fixture();
|
||||
for at in [130, b.len() - 1] {
|
||||
let mut damaged = b.clone();
|
||||
damaged[at] ^= 1;
|
||||
assert_eq!(preview(&damaged).unwrap_err(), Error::Malformed("PNG chunk checksum"));
|
||||
}
|
||||
let mut ancillary = vec![0; 4];
|
||||
ancillary.extend(b"tEXt");
|
||||
ancillary.extend(crc32fast::hash(b"tEXt").to_be_bytes());
|
||||
let mut with_ancillary = b.clone();
|
||||
let at = b.len() - 12;
|
||||
with_ancillary.splice(at..at, ancillary);
|
||||
let png_length = (with_ancillary.len() - 101) as u32;
|
||||
with_ancillary[84..88].copy_from_slice(&(png_length + 13).to_le_bytes());
|
||||
with_ancillary[96..100].copy_from_slice(&png_length.to_le_bytes());
|
||||
assert!(preview(&with_ancillary).is_ok());
|
||||
with_ancillary[at + 8] ^= 1;
|
||||
assert_eq!(preview(&with_ancillary).unwrap_err(), Error::Malformed("PNG chunk checksum"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compressed_metadata_and_animation_are_not_preview_support() {
|
||||
for kind in [b"zTXt", b"iTXt", b"iCCP", b"acTL", b"fcTL", b"fdAT"] {
|
||||
let mut extra = vec![0; 4];
|
||||
extra.extend(kind);
|
||||
extra.extend(crc32fast::hash(kind).to_be_bytes());
|
||||
let mut b = fixture();
|
||||
let at = b.len() - 12;
|
||||
b.splice(at..at, extra);
|
||||
let length = (b.len() - 101) as u32;
|
||||
b[84..88].copy_from_slice(&(length + 13).to_le_bytes());
|
||||
b[96..100].copy_from_slice(&length.to_le_bytes());
|
||||
assert!(matches!(preview(&b), Err(Error::Unsupported(_))));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn critical_chunks_follow_a_single_complete_png_layout() {
|
||||
fn with_chunks(kinds: &[&[u8; 4]]) -> Vec<u8> {
|
||||
let original = fixture();
|
||||
let mut b = original[..134].to_vec(); // File, record and complete first IHDR.
|
||||
for kind in kinds {
|
||||
b.extend([0; 4]);
|
||||
b.extend(*kind);
|
||||
b.extend(crc32fast::hash(*kind).to_be_bytes());
|
||||
}
|
||||
let length = (b.len() - 101) as u32;
|
||||
b[84..88].copy_from_slice(&(length + 13).to_le_bytes());
|
||||
b[96..100].copy_from_slice(&length.to_le_bytes());
|
||||
b
|
||||
}
|
||||
assert!(preview(&with_chunks(&[b"IDAT", b"IDAT", b"IEND"])).is_ok());
|
||||
// PNG readers must not reject an otherwise valid unknown ancillary chunk just
|
||||
// because its reserved (third-letter) bit is set; future versions can define it.
|
||||
assert!(preview(&with_chunks(&[b"abct", b"IDAT", b"IEND"])).is_ok());
|
||||
for kinds in [
|
||||
vec![b"IEND"],
|
||||
vec![b"IDAT", b"IHDR", b"IEND"],
|
||||
vec![b"PLTE", b"PLTE", b"IDAT", b"IEND"],
|
||||
vec![b"IDAT", b"PLTE", b"IEND"],
|
||||
vec![b"IDAT", b"tEXt", b"IDAT", b"IEND"],
|
||||
vec![b"ABCD", b"IDAT", b"IEND"],
|
||||
vec![b"IDAT", b"IEND", b"tEXt"],
|
||||
] {
|
||||
assert!(preview(&with_chunks(&kinds)).is_err(), "{kinds:?}");
|
||||
}
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(256))]
|
||||
#[test]
|
||||
fn arbitrary_input_never_panics(b in prop::collection::vec(any::<u8>(), 0..2048)) {
|
||||
let _ = header(&b);
|
||||
let _ = preview(&b);
|
||||
}
|
||||
#[test]
|
||||
fn mutations_never_panic(edits in prop::collection::vec((0usize..200, any::<u8>()), 0..32)) {
|
||||
let mut b = fixture();
|
||||
for (i, value) in edits { if let Some(v) = b.get_mut(i) { *v = value; } }
|
||||
let _ = preview(&b);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod archive {
|
||||
use crate::Error;
|
||||
use crate::container::{Archive, Limits};
|
||||
use crate::stream::{self, Kind, Tag, Value};
|
||||
use crate::synth::{self, F, Method, tag};
|
||||
use proptest::prelude::*;
|
||||
|
||||
fn doc() -> Vec<u8> {
|
||||
synth::stream(&[
|
||||
(tag(b"Desc"), F::Str("Document".into())),
|
||||
(tag(b"Visi"), F::Bool(true)),
|
||||
(tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, 5.0, 0.0, 1.0, -2.0])),
|
||||
(tag(b"Colr"), F::Def(7, vec![tag(b"RGBA")], vec![(tag(b"_col"), F::Struct([0u8; 16].to_vec()))])),
|
||||
(tag(b"Same"), F::Ref(7)),
|
||||
(tag(b"Null"), F::Null),
|
||||
(tag(b"Kids"), F::Shared(vec![F::Def(8, vec![tag(b"Grup"), tag(b"Node")], vec![(tag(b"Desc"), F::Str("g".into()))]), F::Ref(8)])),
|
||||
(
|
||||
tag(b"Crvs"),
|
||||
F::Obj(tag(b"PCvD"), vec![(tag(b"Data"), F::Pos(vec![F::U8(0), F::U32(1), F::Bool(true), F::Records(18, vec![vec![0; 18]; 4])]))]),
|
||||
),
|
||||
(tag(b"Enum"), F::Enum(2, 1)),
|
||||
(tag(b"Tile"), F::Entry("d/3".into())),
|
||||
(tag(b"Stat"), F::U8s(vec![4, 0, 2])),
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_method_round_trips_with_checksums() {
|
||||
let d = doc();
|
||||
for method in [Method::Stored, Method::Zlib, Method::Zstd] {
|
||||
let file = synth::container(&[("doc.dat", &d, method), ("d/3", b"tile", method)], None);
|
||||
let mut a = Archive::open(&file, Limits::default()).unwrap();
|
||||
assert_eq!(a.header.version, 12);
|
||||
assert_eq!(a.read("doc.dat").unwrap(), d);
|
||||
assert_eq!(a.read("d/3").unwrap(), b"tile");
|
||||
assert_eq!(a.extracted(), d.len() + 4);
|
||||
assert!(a.read("missing").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_stream_parses_into_shared_objects_and_links() {
|
||||
let s = stream::parse(&doc()).unwrap();
|
||||
let root = &s.objects[s.root];
|
||||
assert_eq!(root.class, Tag::of(b"Pers"));
|
||||
assert_eq!(root.get(Tag::of(b"Desc")), Some(&Value::Str("Document".into())));
|
||||
assert_eq!(root.get(Tag::of(b"Xfrm")), Some(&Value::Floats(vec![1.0, 0.0, 5.0, 0.0, 1.0, -2.0])));
|
||||
let (Some(Value::Obj(Some(a))), Some(Value::Obj(Some(b)))) = (root.get(Tag::of(b"Colr")), root.get(Tag::of(b"Same"))) else { panic!() };
|
||||
assert_eq!(a, b);
|
||||
assert_eq!(s.objects[*a].class, Tag::of(b"RGBA"));
|
||||
assert_eq!(s.objects[*a].kind, Kind::Shared);
|
||||
let Some(Value::Array(kids)) = root.get(Tag::of(b"Kids")) else { panic!() };
|
||||
assert_eq!(kids[0], kids[1]);
|
||||
let Value::Obj(Some(g)) = kids[0] else { panic!() };
|
||||
assert_eq!(s.objects[g].chain, vec![Tag::of(b"Grup"), Tag::of(b"Node")]);
|
||||
assert_eq!(root.get(Tag::of(b"Enum")), Some(&Value::Enum { id: 2, version: 1 }));
|
||||
assert_eq!(root.get(Tag::of(b"Tile")), Some(&Value::Entry("d/3".into())));
|
||||
assert_eq!(root.get(Tag::of(b"Null")), Some(&Value::Obj(None)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checksums_sizes_and_budgets_are_enforced() {
|
||||
let d = doc();
|
||||
let file = synth::container(&[("doc.dat", &d, Method::Zstd)], None);
|
||||
// Flip a byte inside the compressed payload, past the "#Fil" tag at offset 72.
|
||||
let mut broken = file.clone();
|
||||
broken[90] ^= 0x55;
|
||||
let mut a = Archive::open(&broken, Limits::default()).unwrap();
|
||||
assert!(a.read("doc.dat").is_err());
|
||||
let mut a = Archive::open(&file, Limits { max_entry: d.len() - 1, max_total: usize::MAX }).unwrap();
|
||||
assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_))));
|
||||
let mut a = Archive::open(&file, Limits { max_entry: usize::MAX, max_total: d.len() + 1 }).unwrap();
|
||||
a.read("doc.dat").unwrap();
|
||||
assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_declared_size_smaller_than_the_data_is_rejected_not_truncated() {
|
||||
let big = vec![7u8; 1 << 20];
|
||||
let mut file = synth::container(&[("doc.dat", &big, Method::Zlib)], None);
|
||||
// Rewrite the declared size in the table to 1 KiB: decoding must stop and fail.
|
||||
let fat = u64::from_le_bytes(file[16..24].try_into().unwrap()) as usize;
|
||||
let size_at = fat + 59 + 5 + 8;
|
||||
file[size_at..size_at + 8].copy_from_slice(&1024u64.to_le_bytes());
|
||||
let mut a = Archive::open(&file, Limits::default()).unwrap();
|
||||
assert_eq!(a.read("doc.dat").unwrap_err(), Error::Malformed("archive entry size mismatch"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn libraries_and_cyclic_tables_are_rejected() {
|
||||
let d = doc();
|
||||
let mut file = synth::container(&[("doc.dat", &d, Method::Stored)], None);
|
||||
let mut library = file.clone();
|
||||
library[8..12].copy_from_slice(b"rAsA");
|
||||
assert!(matches!(Archive::open(&library, Limits::default()), Err(Error::Unsupported(_))));
|
||||
let fat = u64::from_le_bytes(file[16..24].try_into().unwrap());
|
||||
let at = fat as usize + 4;
|
||||
file[at..at + 8].copy_from_slice(&fat.to_le_bytes());
|
||||
assert_eq!(Archive::open(&file, Limits::default()).unwrap_err(), Error::Malformed("allocation tables form a cycle"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_streams_fail_cleanly() {
|
||||
let deep = (0..2000).fold(Vec::new(), |inner, _| vec![(tag(b"Chld"), F::Obj(tag(b"Grup"), inner))]);
|
||||
assert_eq!(stream::parse(&synth::stream(&deep)).unwrap_err(), Error::Limit("objects nested too deeply"));
|
||||
// An array claiming four billion elements in a few bytes.
|
||||
let huge = synth::stream(&[(tag(b"Big_"), F::Raw(vec![0x81, b'_', b'g', b'i', b'B', 0xff, 0xff, 0xff, 0xff, 0]))]);
|
||||
assert_eq!(stream::parse(&huge).unwrap_err(), Error::Malformed("array longer than its data"));
|
||||
let forward = synth::stream(&[(tag(b"Link"), F::Ref(99))]);
|
||||
assert!(stream::parse(&forward).is_err());
|
||||
let twice = synth::stream(&[(tag(b"A___"), F::Def(1, vec![tag(b"RGBA")], vec![])), (tag(b"B___"), F::Def(1, vec![tag(b"RGBA")], vec![]))]);
|
||||
assert!(stream::parse(&twice).is_err());
|
||||
let unknown = synth::stream(&[(tag(b"X___"), F::Raw(vec![0x0b, 1, 2, 3, 4]))]);
|
||||
assert!(matches!(stream::parse(&unknown), Err(Error::Unsupported(_))));
|
||||
let d = doc();
|
||||
for end in 0..d.len() {
|
||||
assert!(stream::parse(&d[..end]).is_err(), "{end}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_small_compressed_entry_cannot_make_the_stream_allocate_gigabytes() {
|
||||
// Each decoded value takes about 40 bytes, and a bool array packs eight in a byte: 16 MiB
|
||||
// of zero bits, a few KiB once compressed, would ask for 2^27 values (about 4 GiB). An
|
||||
// array of one-byte values just past the budget fails the same way, before allocating.
|
||||
let field = |code: u8, n: u32, data: usize| {
|
||||
let mut f = vec![0x80 | code];
|
||||
f.extend(tag(b"Big_").0.to_le_bytes());
|
||||
f.extend(n.to_le_bytes());
|
||||
f.resize(f.len() + data, 0);
|
||||
F::Raw(f)
|
||||
};
|
||||
let bools = 1u32 << 27;
|
||||
let bytes = u32::try_from(stream::MAX_VALUES).unwrap() + 1;
|
||||
for f in [field(0x29, bools, (bools / 8) as usize), field(0x01, bytes, bytes as usize)] {
|
||||
let d = synth::stream(&[(tag(b"Big_"), f)]);
|
||||
let file = synth::container(&[("doc.dat", &d, Method::Zstd)], None);
|
||||
assert!(file.len() < d.len() / 100, "{} of {}", file.len(), d.len());
|
||||
let entry = Archive::open(&file, Limits::default()).unwrap().read("doc.dat").unwrap();
|
||||
assert_eq!(stream::parse(&entry).unwrap_err(), Error::Limit("too many values in the document stream"));
|
||||
}
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(512))]
|
||||
#[test]
|
||||
fn mutated_containers_and_streams_never_panic(edits in prop::collection::vec((0usize..4096, any::<u8>()), 1..24)) {
|
||||
let d = doc();
|
||||
for method in [Method::Stored, Method::Zlib, Method::Zstd] {
|
||||
let mut file = synth::container(&[("doc.dat", &d, method)], None);
|
||||
for (i, v) in &edits {
|
||||
let n = file.len();
|
||||
file[i % n] = *v;
|
||||
}
|
||||
if let Ok(bytes) = Archive::open(&file, Limits::default()).and_then(|mut a| a.read("doc.dat")) {
|
||||
let _ = stream::parse(&bytes);
|
||||
}
|
||||
}
|
||||
let mut s = d.clone();
|
||||
for (i, v) in &edits {
|
||||
let n = s.len();
|
||||
s[i % n] = *v;
|
||||
}
|
||||
let _ = stream::parse(&s);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
//! Artistic and frame text: the story's characters with their font, size, colour, tracking and
|
||||
//! leading, the paragraph alignment and the first baseline. Layout (line breaks in frames, kerning,
|
||||
//! OpenType features) is left to the importing application.
|
||||
|
||||
use crate::model::{Affine, Align, Point, Reader, Text, TextRun, rect};
|
||||
use crate::paint::{self, Paint};
|
||||
use crate::stream::{ObjId, Value};
|
||||
|
||||
/// Characters read per text node (stories are split into blocks; this caps hostile ones).
|
||||
const MAX_CHARS: usize = 1 << 20;
|
||||
|
||||
pub(crate) fn read(r: &mut Reader, id: ObjId, world: Affine) -> Option<Text> {
|
||||
let s = r.s;
|
||||
let story = s.obj(id, b"StSt")?;
|
||||
let frame = s.obj(id, b"TxtH")?;
|
||||
let artistic = s.is(frame, b"ArFr");
|
||||
let bounds = s.floats::<4>(frame, b"FrmB").map(rect)?;
|
||||
let mut runs = Vec::new();
|
||||
let mut align = None;
|
||||
let mut total = 0usize;
|
||||
let mut placeholders = false;
|
||||
for block in s.objs(story, b"Blok") {
|
||||
let glyphs = s.obj(block, b"Glyp")?;
|
||||
let chars: Vec<char> = match s.str(glyphs, b"Utf8") {
|
||||
Some(t) => t.chars().collect(),
|
||||
None => {
|
||||
// Inline objects (page numbers, anchors) each take one index before the segment text.
|
||||
let mut v = Vec::new();
|
||||
for seg in s.objs(glyphs, b"Mixd") {
|
||||
let inline = s.objs(seg, b"Glys").len();
|
||||
v.extend(std::iter::repeat_n('\u{FFFC}', inline));
|
||||
placeholders |= inline > 0;
|
||||
v.extend(s.str(seg, b"Utf8").unwrap_or_default().chars());
|
||||
}
|
||||
v
|
||||
}
|
||||
};
|
||||
total = total.checked_add(chars.len())?;
|
||||
if total > MAX_CHARS {
|
||||
r.warn("text longer than a million characters (truncated)");
|
||||
break;
|
||||
}
|
||||
let mut start = 0usize;
|
||||
for run in s.obj(block, b"GAtt").map(|g| s.objs(g, b"Runs")).unwrap_or_default() {
|
||||
let end = usize::try_from(s.int(run, b"Indx")?).ok()?.min(chars.len());
|
||||
if end <= start {
|
||||
continue;
|
||||
}
|
||||
let text: String = chars.get(start..end)?.iter().filter(|c| **c != '\0' && **c != '\u{FFFC}').collect();
|
||||
start = end;
|
||||
let Some(attrs) = s.obj(run, b"Item") else { continue };
|
||||
if !text.is_empty() {
|
||||
runs.push(run_attrs(r, attrs, text, world));
|
||||
}
|
||||
}
|
||||
if align.is_none() {
|
||||
align = s
|
||||
.obj(block, b"PAtt")
|
||||
.and_then(|p| s.objs(p, b"Runs").first().copied())
|
||||
.and_then(|run| s.obj(run, b"Item"))
|
||||
.map(|p| paragraph_align(r, p));
|
||||
}
|
||||
}
|
||||
if placeholders {
|
||||
r.warn("text fields such as page numbers (left out of the text)");
|
||||
}
|
||||
let align = align.unwrap_or(Align::Left);
|
||||
let first_baseline = bounds.y0 + s.f64(frame, b"ArtV").unwrap_or(0.0);
|
||||
let anchor_x = match align {
|
||||
Align::Center => (bounds.x0 + bounds.x1) / 2.0,
|
||||
Align::Right => bounds.x1,
|
||||
_ => bounds.x0,
|
||||
};
|
||||
if !artistic {
|
||||
if s.objs(frame, b"ColW").len() > 1 || matches!(s.field(frame, b"ColW"), Some(Value::Array(a)) if a.len() > 1) {
|
||||
r.warn("text frames with several columns (imported as one column)");
|
||||
}
|
||||
if s.class(id) == Some(crate::stream::Tag::of(b"TxtC")) {
|
||||
r.warn("text frames with a curved outline (imported as rectangular frames)");
|
||||
}
|
||||
}
|
||||
Some(Text { runs, align, anchor: Point { x: anchor_x, y: first_baseline }, frame: if artistic { None } else { Some(bounds) }, transform: world })
|
||||
}
|
||||
|
||||
fn slot<T: Copy>(v: Option<&Value>, i: usize, f: impl Fn(&Value) -> Option<T>) -> Option<T> {
|
||||
match v? {
|
||||
Value::Array(a) => a.get(i).and_then(f),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn float(v: &Value) -> Option<f64> {
|
||||
match v {
|
||||
Value::Float(f) if f.is_finite() => Some(*f),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn int(v: &Value) -> Option<i64> {
|
||||
match v {
|
||||
Value::Int(i) => Some(*i),
|
||||
Value::UInt(u) => i64::try_from(*u).ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine) -> TextRun {
|
||||
let s = r.s;
|
||||
let doubles = s.field(attrs, b"Doub");
|
||||
let ints = s.field(attrs, b"Ints");
|
||||
let size = slot(doubles, 0, float).filter(|v| *v > 0.0 && *v < 1e6).unwrap_or(12.0 * r.dpi / 72.0);
|
||||
let tracking = slot(doubles, 4, float).unwrap_or(0.0);
|
||||
let leading = (slot(ints, 4, int) == Some(1)).then(|| slot(doubles, 13, float)).flatten().filter(|v| *v > 0.0);
|
||||
let objects = match s.field(attrs, b"Objs") {
|
||||
Some(Value::Array(a)) => a.iter().map(|v| if let Value::Obj(o) = v { *o } else { None }).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let fill = objects.first().copied().flatten().and_then(|f| paint::descriptor(r, f, world)).unwrap_or(Paint::None);
|
||||
if objects.get(1).copied().flatten().and_then(|f| paint::descriptor(r, f, world)).is_some_and(|p| p != Paint::None) {
|
||||
r.warn("outlined (stroked) text");
|
||||
}
|
||||
if slot(doubles, 10, float).is_some_and(|h| (h - 1.0).abs() > 1e-3) {
|
||||
r.warn("horizontally scaled text");
|
||||
}
|
||||
let font = s.obj(attrs, b"DFnt");
|
||||
let get = |tag: &[u8; 4]| font.and_then(|f| s.str(f, tag)).unwrap_or_default().to_string();
|
||||
TextRun {
|
||||
text,
|
||||
postscript: get(b"Post"),
|
||||
family: get(b"Famy"),
|
||||
weight: font.and_then(|f| s.int(f, b"Wegt")).unwrap_or(400),
|
||||
italic: font.and_then(|f| s.bool(f, b"Ital")).unwrap_or(false),
|
||||
size,
|
||||
tracking,
|
||||
leading,
|
||||
fill,
|
||||
}
|
||||
}
|
||||
|
||||
fn paragraph_align(r: &mut Reader, p: ObjId) -> Align {
|
||||
match slot(r.s.field(p, b"Ints"), 0, int) {
|
||||
Some(1) => Align::Center,
|
||||
Some(2) => Align::Right,
|
||||
Some(3) => Align::Justify,
|
||||
Some(0) | None => Align::Left,
|
||||
Some(_) => {
|
||||
r.warn("an unknown paragraph alignment (imported as left)");
|
||||
Align::Left
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//! Public Affinity documents: the pinned set `cargo xtask corpus --affinity` fetches into
|
||||
//! `corpus/affinity`, or any folder of `.af`/`.afdesign`/`.afphoto`/`.afpub` files named by
|
||||
//! `AFFINITY_SAMPLES` (searched recursively). Without either, these tests do nothing.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use vectorcraft_affinity::{Archive, Limits, stream};
|
||||
|
||||
fn files(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
for e in std::fs::read_dir(dir).into_iter().flatten().flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
files(&p, out);
|
||||
} else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| ["af", "afdesign", "afphoto", "afpub", "aftemplate"].contains(&e))
|
||||
&& std::fs::read(&p).is_ok_and(|b| b.starts_with(vectorcraft_affinity::MAGIC))
|
||||
{
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn samples() -> Option<PathBuf> {
|
||||
let pinned = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../corpus/affinity");
|
||||
std::env::var_os("AFFINITY_SAMPLES").map(PathBuf::from).or_else(|| pinned.join("SOURCES.md").exists().then_some(pinned))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_documents_parse_completely() {
|
||||
let Some(dir) = samples() else { return };
|
||||
let mut paths = Vec::new();
|
||||
files(&dir, &mut paths);
|
||||
assert!(!paths.is_empty(), "no Affinity documents under {dir:?}");
|
||||
let mut failures = Vec::new();
|
||||
for p in &paths {
|
||||
let bytes = std::fs::read(p).unwrap();
|
||||
let result = Archive::open(&bytes, Limits::default()).and_then(|mut a| {
|
||||
let doc = a.read("doc.dat")?;
|
||||
stream::parse(&doc).map(|s| s.objects.len())
|
||||
});
|
||||
match result {
|
||||
Ok(n) => assert!(n > 0),
|
||||
Err(e) => failures.push(format!("{}: {e}", p.display())),
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "{} of {} failed:\n{}", failures.len(), paths.len(), failures.join("\n"));
|
||||
eprintln!("{} Affinity documents parsed", paths.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_documents_map_to_the_model() {
|
||||
let Some(dir) = samples() else { return };
|
||||
let mut paths = Vec::new();
|
||||
files(&dir, &mut paths);
|
||||
let mut warnings = std::collections::BTreeMap::<String, usize>::new();
|
||||
let mut failures = Vec::new();
|
||||
let (mut nodes, mut docs) = (0usize, 0usize);
|
||||
fn count(n: &[vectorcraft_affinity::model::Node]) -> usize {
|
||||
n.iter().map(|n| 1 + count(&n.children)).sum()
|
||||
}
|
||||
for p in &paths {
|
||||
let bytes = std::fs::read(p).unwrap();
|
||||
if !bytes.starts_with(vectorcraft_affinity::MAGIC) {
|
||||
continue;
|
||||
}
|
||||
match vectorcraft_affinity::read(&bytes, Limits::default()) {
|
||||
Ok(d) => {
|
||||
docs += 1;
|
||||
nodes += d.spreads.iter().map(|s| count(&s.nodes)).sum::<usize>();
|
||||
for w in d.warnings {
|
||||
let key = w.split(" (").next().unwrap_or(&w).to_string();
|
||||
*warnings.entry(key).or_default() += 1;
|
||||
}
|
||||
}
|
||||
Err(e) => failures.push(format!("{}: {e}", p.display())),
|
||||
}
|
||||
}
|
||||
eprintln!("{docs} documents, {nodes} nodes");
|
||||
for (w, n) in &warnings {
|
||||
eprintln!("{n:4} documents: {w}");
|
||||
}
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
}
|
||||
@@ -5,7 +5,8 @@
|
||||
//!
|
||||
//! - **Geometry effects** (Distort & Transform, Path, Convert to Shape, Round Corners, Scribble,
|
||||
//! Warp) rewrite a path: [`apply_geometry`] evaluates them in stack order.
|
||||
//! - **Raster effects** (Drop Shadow, Inner/Outer Glow, Feather, Gaussian Blur) are described by
|
||||
//! - **Raster effects** (Drop Shadow, Inner/Outer Glow, Feather, Gaussian Blur, and the
|
||||
//! Photoshop-style filters of [`pixel`]: Radial Blur, Smart Blur, Unsharp Mask) are described by
|
||||
//! [`raster_effects`] and painted by the renderer; [`outset`] says how far they reach beyond
|
||||
//! the geometry.
|
||||
//! - **Stroke geometry** ([`stroke`]): arrowheads, dash patterns and width profiles, shared by the
|
||||
@@ -36,6 +37,7 @@ mod distort;
|
||||
mod group;
|
||||
mod live;
|
||||
mod marks;
|
||||
pub mod pixel;
|
||||
mod raster;
|
||||
mod reshape;
|
||||
pub mod stroke;
|
||||
@@ -61,6 +63,7 @@ pub use group::{
|
||||
};
|
||||
pub use live::{expand_live, expand_live_deep, expanded_live_group, text_outliner};
|
||||
pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks};
|
||||
pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace};
|
||||
pub use raster::{RasterFx, outset, raster_effects};
|
||||
pub use reshape::{expand_outlined, needs_outline, outline_art, outline_text, reshape};
|
||||
pub use warp::{WarpStyle, warp_point};
|
||||
@@ -118,7 +121,7 @@ fn lengths_of(id: &str) -> Lengths {
|
||||
"distort.transform" => always(&["moveH", "moveV"]),
|
||||
"path.offsetPath" => always(&["offset"]),
|
||||
"path.outlineStroke" => always(&["width"]),
|
||||
"stylize.roundCorners" | "stylize.feather" | "blur.gaussian" => always(&["radius"]),
|
||||
"stylize.roundCorners" | "stylize.feather" | "blur.gaussian" | "blur.smart" | "sharpen.unsharpMask" => always(&["radius"]),
|
||||
"stylize.scribble" => always(&["overlap", "strokeWidth", "spacing", "variation"]),
|
||||
"stylize.dropShadow" => always(&["x", "y", "blur"]),
|
||||
"stylize.innerGlow" | "stylize.outerGlow" => always(&["blur"]),
|
||||
@@ -134,6 +137,7 @@ const SHAPE: &[&str] = &["Effect", "Convert to Shape"];
|
||||
const STYLIZE: &[&str] = &["Effect", "Stylize"];
|
||||
const WARP: &[&str] = &["Effect", "Warp"];
|
||||
const BLUR: &[&str] = &["Effect", "Blur"];
|
||||
const SHARPEN: &[&str] = &["Effect", "Sharpen"];
|
||||
const PATHFINDER: &[&str] = &["Effect", "Pathfinder"];
|
||||
const PLUGINS: &[&str] = &["Effect", "Plug-ins"];
|
||||
const ADJUST: &[&str] = &["Effect", "Color Adjustments"];
|
||||
@@ -265,6 +269,27 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
),
|
||||
r("stylize.feather", "Feather…", STYLIZE, "{radius: pt (5)}", json!({"radius": 5.0})),
|
||||
r("blur.gaussian", "Gaussian Blur…", BLUR, "{radius: pt (5)}", json!({"radius": 5.0})),
|
||||
r(
|
||||
"blur.radial",
|
||||
"Radial Blur…",
|
||||
BLUR,
|
||||
"{amount: 1..100 (10; spin: an arc of `amount`°, zoom: the content scaled by up to about ±amount/2 %), method: \"spin\"|\"zoom\" (\"spin\"), quality: \"draft\"|\"good\"|\"best\" (\"good\"; 16, 64 or 256 samples)} blurs around the object's centre",
|
||||
json!({"amount": 10.0, "method": "spin", "quality": "good"}),
|
||||
),
|
||||
r(
|
||||
"blur.smart",
|
||||
"Smart Blur…",
|
||||
BLUR,
|
||||
"{radius: pt 0.1..100 (3), threshold: levels 0.1..100 (25; only colours closer than this blur together, so edges stay sharp), quality: \"low\"|\"medium\"|\"high\" (\"medium\"; 5, 7 or 9 samples across)} (Normal mode)",
|
||||
json!({"radius": 3.0, "threshold": 25.0, "quality": "medium"}),
|
||||
),
|
||||
r(
|
||||
"sharpen.unsharpMask",
|
||||
"Unsharp Mask…",
|
||||
SHARPEN,
|
||||
"{amount: % 1..500 (50), radius: pt 0.1..250 (1; σ of the blur edges are found against), threshold: levels 0..255 (0; smaller differences are left alone)}",
|
||||
json!({"amount": 50.0, "radius": 1.0, "threshold": 0.0}),
|
||||
),
|
||||
];
|
||||
v.extend([
|
||||
g(
|
||||
@@ -428,6 +453,7 @@ pub fn scale_effect(e: &mut Effect, s: f64) {
|
||||
/// Is `id` a raster (painted) effect?
|
||||
pub fn is_raster(id: &str) -> bool {
|
||||
matches!(id, "stylize.dropShadow" | "stylize.innerGlow" | "stylize.outerGlow" | "stylize.feather" | "blur.gaussian")
|
||||
|| PIXEL_EFFECTS.contains(&id)
|
||||
}
|
||||
|
||||
/// Does `id` change geometry? (Crop Marks adds art of its own instead, [`crop_marks_art`]; colour
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
//! Blur › Radial Blur and Smart Blur.
|
||||
|
||||
use vectorcraft_geom::{Affine, Point, Vec2};
|
||||
|
||||
use super::{PixelSpace, Px16, box_blur, to8, to16};
|
||||
|
||||
/// Zoom's total spread, in log scale: `amount` 100 scales the content between 1/√2 and √2.
|
||||
pub(super) fn zoom_extent(amount: f64) -> f64 {
|
||||
(1.0 + amount / 100.0).ln()
|
||||
}
|
||||
|
||||
/// `buf` (`w` × `h`) sampled bilinearly at pixel index position (`x`, `y`) (pixel centres at
|
||||
/// whole coordinates); transparent outside.
|
||||
fn bilinear(buf: &[Px16], w: usize, h: usize, x: f64, y: f64) -> [f32; 4] {
|
||||
if !(x > -1.0 && y > -1.0 && x < w as f64 && y < h as f64) {
|
||||
return [0.0; 4];
|
||||
}
|
||||
let (x0, y0) = (x.floor(), y.floor());
|
||||
let (fx, fy) = ((x - x0) as f32, (y - y0) as f32);
|
||||
let (xi, yi) = (x0 as isize, y0 as isize);
|
||||
let at = |cx: isize, cy: isize| -> [f32; 4] {
|
||||
if cx < 0 || cy < 0 || cx as usize >= w || cy as usize >= h {
|
||||
return [0.0; 4];
|
||||
}
|
||||
buf.get(cy as usize * w + cx as usize).map_or([0.0; 4], |p| p.map(f32::from))
|
||||
};
|
||||
let (a, b, c, d) = (at(xi, yi), at(xi + 1, yi), at(xi, yi + 1), at(xi + 1, yi + 1));
|
||||
let (wa, wb, wc, wd) = ((1.0 - fx) * (1.0 - fy), fx * (1.0 - fy), (1.0 - fx) * fy, fx * fy);
|
||||
std::array::from_fn(|i| a[i] * wa + b[i] * wb + c[i] * wc + d[i] * wd)
|
||||
}
|
||||
|
||||
/// Radial Blur: the average of the content turned (spin) or scaled (zoom) about the centre over
|
||||
/// evenly spaced steps spanning `amount`. Each pass averages the previous result with itself
|
||||
/// turned both ways by half the last step, so `passes` passes average 2^`passes` samples at two
|
||||
/// lookups a pixel each.
|
||||
pub(super) fn radial(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, amount: f64, zoom: bool, passes: u32) {
|
||||
// The centre in pixel index coordinates (pixel centres at whole numbers).
|
||||
let c = space.to_doc.inverse() * space.center - Vec2::new(0.5, 0.5);
|
||||
if !(c.x.is_finite() && c.y.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let total = if zoom { zoom_extent(amount) } else { amount.to_radians() };
|
||||
let about = |m: Affine| Affine::translate(c.to_vec2()) * m * Affine::translate(-c.to_vec2());
|
||||
// A pixel's reflection in the view (one axis flipped) only turns the spin the other way,
|
||||
// which a symmetric blur doesn't notice.
|
||||
let step = |d: f64| if zoom { about(Affine::scale(d.exp())) } else { about(Affine::rotate(d)) };
|
||||
let mut cur = to16(px);
|
||||
let mut next = vec![[0u16; 4]; cur.len()];
|
||||
for j in 1..=passes.min(12) {
|
||||
let d = total / f64::powi(2.0, j as i32 + 1);
|
||||
let (fwd, back) = (step(d), step(-d));
|
||||
// Where a pixel's two samples lie moves by a fixed step along a row.
|
||||
let (df, db) = (fwd * Point::new(1.0, 0.0) - fwd * Point::ZERO, back * Point::new(1.0, 0.0) - back * Point::ZERO);
|
||||
for (y, row) in next.chunks_exact_mut(w).enumerate().take(h) {
|
||||
let (mut f, mut b) = (fwd * Point::new(0.0, y as f64), back * Point::new(0.0, y as f64));
|
||||
for out in row.iter_mut() {
|
||||
let (s, t) = (bilinear(&cur, w, h, f.x, f.y), bilinear(&cur, w, h, b.x, b.y));
|
||||
*out = std::array::from_fn(|i| ((s[i] + t[i]) * 0.5).round().clamp(0.0, 65535.0) as u16);
|
||||
(f, b) = (f + df, b + db);
|
||||
}
|
||||
}
|
||||
std::mem::swap(&mut cur, &mut next);
|
||||
}
|
||||
for (p, q) in px.iter_mut().zip(&cur) {
|
||||
*p = q.map(to8);
|
||||
}
|
||||
}
|
||||
|
||||
/// The largest radius (pixels) Smart Blur looks over: farther neighbours are as good as gone.
|
||||
const MAX_SMART: f64 = 4096.0;
|
||||
|
||||
/// Smart Blur (Normal mode): each pixel averaged with itself and the neighbours within `radius`
|
||||
/// pixels whose premultiplied channels all lie within `threshold` levels of its own. Wide radii
|
||||
/// look at `samples` × `samples` neighbours spread over the disc, read from a copy box-blurred
|
||||
/// over the gaps between them so the blur stays smooth (a neighbour counts when both it and its
|
||||
/// blurred surroundings are within the threshold, so at most a fringe under the threshold crosses
|
||||
/// an edge).
|
||||
pub(super) fn smart(px: &mut [[u8; 4]], w: usize, h: usize, radius: f64, threshold: f64, samples: u32) {
|
||||
if radius.is_nan() || radius < 0.5 {
|
||||
return;
|
||||
}
|
||||
let r = radius.min(MAX_SMART);
|
||||
let n = samples.clamp(3, 15) as f64;
|
||||
let k = r.round();
|
||||
let (offsets, prefilter): (Vec<Vec2>, usize) = if 2.0 * k < n {
|
||||
let k = k as i32;
|
||||
((-k..=k).flat_map(|dy| (-k..=k).map(move |dx| Vec2::new(dx as f64, dy as f64))).collect(), 0)
|
||||
} else {
|
||||
let gap = 2.0 * r / (n - 1.0);
|
||||
let grid = (0..n as i32).map(|i| -r + gap * i as f64);
|
||||
(grid.clone().flat_map(|dy| grid.clone().map(move |dx| Vec2::new(dx.round(), dy.round()))).collect(), (gap / 2.0).floor() as usize)
|
||||
};
|
||||
let offsets: Vec<(isize, isize)> =
|
||||
offsets.into_iter().filter(|o| o.hypot() <= r + 0.5 && *o != Vec2::ZERO).map(|o| (o.x as isize, o.y as isize)).collect();
|
||||
let src = to16(px);
|
||||
let blurred;
|
||||
let near: &[Px16] = if prefilter > 0 {
|
||||
let mut b = src.clone();
|
||||
box_blur(&mut b, w, prefilter);
|
||||
blurred = b;
|
||||
&blurred
|
||||
} else {
|
||||
&src
|
||||
};
|
||||
let t = (threshold.clamp(0.0, 255.0) * 257.0) as i32;
|
||||
for (y, (row, out)) in src.chunks_exact(w).zip(px.chunks_exact_mut(w)).enumerate().take(h) {
|
||||
for (x, (c, o)) in row.iter().zip(out.iter_mut()).enumerate() {
|
||||
let mut acc = c.map(u64::from);
|
||||
let mut count = 1u64;
|
||||
for (dx, dy) in &offsets {
|
||||
let (sx, sy) = (x as isize + dx, y as isize + dy);
|
||||
if sx < 0 || sy < 0 || sx as usize >= w || sy as usize >= h {
|
||||
continue;
|
||||
}
|
||||
let i = sy as usize * w + sx as usize;
|
||||
let (Some(s), Some(own)) = (near.get(i), src.get(i)) else { continue };
|
||||
// The neighbour itself and its surroundings both close enough: edges stay sharp.
|
||||
let close = |p: &Px16| p.iter().zip(c).all(|(a, b)| (*a as i32 - *b as i32).abs() <= t);
|
||||
if close(own) && close(s) {
|
||||
for (a, v) in acc.iter_mut().zip(s) {
|
||||
*a += *v as u64;
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
*o = acc.map(|a| to8(((a + count / 2) / count).min(65535) as u16));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
//! Photoshop-style raster effects (Effect › Blur › Radial Blur and Smart Blur, Sharpen › Unsharp
|
||||
//! Mask): filters over the pixels of an object rendered alone, premultiplied RGBA8.
|
||||
//!
|
||||
//! - Every distance is in document units (points) and becomes pixels through the raster's
|
||||
//! [`PixelSpace::px`], so an effect looks the same at any zoom and at any Document Raster Effects
|
||||
//! Settings resolution (as Gaussian Blur's radius does).
|
||||
//! - The work per pixel is bounded whatever the parameters: blurs are box approximations whose
|
||||
//! cost doesn't grow with the radius, and sample counts come from the effect's quality.
|
||||
//! - Beyond the raster's edges is transparency.
|
||||
|
||||
mod blur;
|
||||
mod sharpen;
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_geom::{Affine, Point, Rect};
|
||||
|
||||
use crate::util::{num, text};
|
||||
|
||||
/// The Photoshop-style effect ids (all raster effects, see [`crate::is_raster`]).
|
||||
pub const PIXEL_EFFECTS: [&str; 3] = ["blur.radial", "blur.smart", "sharpen.unsharpMask"];
|
||||
|
||||
/// A Photoshop-style raster effect with its parameters read and clamped to their ranges. Lengths
|
||||
/// are in document units.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum PixelFx {
|
||||
/// Blur › Radial Blur around the object's centre: along arcs (spin, an arc of `amount`°) or
|
||||
/// along rays (zoom, the content scaled by up to about ±`amount`/2 %), averaging 2^`passes` samples.
|
||||
RadialBlur { amount: f64, zoom: bool, passes: u32 },
|
||||
/// Blur › Smart Blur (Normal mode): each pixel averaged with the neighbours within `radius`
|
||||
/// whose colour differs from its own by no more than `threshold` levels, over a grid of
|
||||
/// `samples` × `samples` neighbours at most.
|
||||
SmartBlur { radius: f64, threshold: f64, samples: u32 },
|
||||
/// Sharpen › Unsharp Mask: colours pushed away from a Gaussian blur of σ = `radius` by `amount`
|
||||
/// (1 = 100 %), where they differ from it by at least `threshold` levels.
|
||||
UnsharpMask { amount: f64, radius: f64, threshold: f64 },
|
||||
}
|
||||
|
||||
/// Where a raster's pixels lie in the document.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct PixelSpace {
|
||||
/// Pixel coordinates (x right, y down, (0, 0) the top-left corner of the first pixel) →
|
||||
/// document coordinates.
|
||||
pub to_doc: Affine,
|
||||
/// The size of a pixel in document units.
|
||||
pub px: f64,
|
||||
/// The object's centre (document coordinates): where Radial Blur blurs around.
|
||||
pub center: Point,
|
||||
}
|
||||
|
||||
/// Effect `id`'s parameters `p` (defaults merged in) as a [`PixelFx`]; `None` for other effects.
|
||||
pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
|
||||
let quality = |levels: [(&str, u32); 3], default: u32| {
|
||||
let q = text(p, "quality", "");
|
||||
levels.iter().find(|(name, _)| name.eq_ignore_ascii_case(q)).map_or(default, |(_, v)| *v)
|
||||
};
|
||||
Some(match id {
|
||||
"blur.radial" => PixelFx::RadialBlur {
|
||||
amount: num(p, "amount", 10.0).clamp(1.0, 100.0),
|
||||
zoom: text(p, "method", "spin").eq_ignore_ascii_case("zoom"),
|
||||
passes: quality([("draft", 4), ("good", 6), ("best", 8)], 6),
|
||||
},
|
||||
"blur.smart" => PixelFx::SmartBlur {
|
||||
radius: num(p, "radius", 3.0).clamp(0.1, 100.0),
|
||||
threshold: num(p, "threshold", 25.0).clamp(0.1, 100.0),
|
||||
samples: quality([("low", 5), ("medium", 7), ("high", 9)], 7),
|
||||
},
|
||||
"sharpen.unsharpMask" => PixelFx::UnsharpMask {
|
||||
amount: num(p, "amount", 50.0).clamp(1.0, 500.0) / 100.0,
|
||||
radius: num(p, "radius", 1.0).clamp(0.1, 250.0),
|
||||
threshold: num(p, "threshold", 0.0).clamp(0.0, 255.0),
|
||||
},
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
impl PixelFx {
|
||||
/// How far beyond `bounds` (the content's, document units) the effect can paint.
|
||||
pub fn outset(&self, bounds: Rect) -> f64 {
|
||||
// From the centre to the farthest corner, and to the nearest side.
|
||||
let far = 0.5 * bounds.width().hypot(bounds.height());
|
||||
let near = 0.5 * bounds.width().min(bounds.height());
|
||||
match *self {
|
||||
// A rotated corner reaches the circle through the corners.
|
||||
PixelFx::RadialBlur { zoom: false, .. } => (far - near).max(0.0),
|
||||
PixelFx::RadialBlur { amount, zoom: true, .. } => far * (blur::zoom_extent(amount).exp() - 1.0),
|
||||
PixelFx::SmartBlur { radius, .. } => radius,
|
||||
PixelFx::UnsharpMask { .. } => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// How far (document units) from a pixel the content that decides it lies; `None` when that
|
||||
/// is anywhere in the object (Radial Blur).
|
||||
pub fn reach(&self) -> Option<f64> {
|
||||
match *self {
|
||||
PixelFx::RadialBlur { .. } => None,
|
||||
PixelFx::SmartBlur { radius, .. } => Some(radius),
|
||||
PixelFx::UnsharpMask { radius, .. } => Some(3.0 * radius),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the effect on `data`, `w` × `h` premultiplied RGBA8 pixels lying in the document as
|
||||
/// `space` says. Does nothing when the sizes don't match.
|
||||
pub fn apply(&self, data: &mut [u8], w: usize, h: usize, space: &PixelSpace) {
|
||||
let Some(px) = pixels(data, w, h) else { return };
|
||||
let to_px = |len: f64| len / space.px.max(1e-9);
|
||||
match *self {
|
||||
PixelFx::RadialBlur { amount, zoom, passes } => blur::radial(px, w, h, space, amount, zoom, passes),
|
||||
PixelFx::SmartBlur { radius, threshold, samples } => blur::smart(px, w, h, to_px(radius), threshold, samples),
|
||||
PixelFx::UnsharpMask { amount, radius, threshold } => sharpen::unsharp(px, w, h, amount, to_px(radius), threshold),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `data` as `w` × `h` RGBA pixels, `None` when it isn't that size (or is empty).
|
||||
fn pixels(data: &mut [u8], w: usize, h: usize) -> Option<&mut [[u8; 4]]> {
|
||||
let (px, rest) = data.as_chunks_mut::<4>();
|
||||
(w > 0 && h > 0 && rest.is_empty() && w.checked_mul(h) == Some(px.len())).then_some(px)
|
||||
}
|
||||
|
||||
/// The largest box radius the blurs use: wider boxes look the same on any raster we make.
|
||||
const MAX_BOX: usize = 1 << 17;
|
||||
|
||||
/// Radii of three box blurs approximating a Gaussian of `sigma` pixels.
|
||||
pub fn gauss_boxes(sigma: f64) -> [usize; 3] {
|
||||
let sigma = if sigma.is_finite() { sigma.clamp(0.0, MAX_BOX as f64 / 2.0) } else { 0.0 };
|
||||
let n = 3.0;
|
||||
let w_ideal = (12.0 * sigma * sigma / n + 1.0).sqrt();
|
||||
let mut wl = w_ideal.floor() as i64;
|
||||
if wl % 2 == 0 {
|
||||
wl -= 1;
|
||||
}
|
||||
let wu = wl + 2;
|
||||
let m_ideal = (12.0 * sigma * sigma - n * (wl * wl) as f64 - 4.0 * n * wl as f64 - 3.0 * n) / (-4.0 * wl as f64 - 4.0);
|
||||
let m = m_ideal.round() as i64;
|
||||
let r = |i: i64| ((((if i < m { wl } else { wu }) - 1) / 2).max(0) as usize).min(MAX_BOX);
|
||||
[r(0), r(1), r(2)]
|
||||
}
|
||||
|
||||
/// One horizontal box blur of radius `r` over the rows of `w` values of a plane (running sum,
|
||||
/// zero past the ends).
|
||||
fn box_plane(src: &[f32], dst: &mut [f32], w: usize, r: usize) {
|
||||
if r == 0 {
|
||||
dst.copy_from_slice(src);
|
||||
return;
|
||||
}
|
||||
let norm = 1.0 / (2 * r + 1) as f32;
|
||||
for (row, out) in src.chunks_exact(w).zip(dst.chunks_exact_mut(w)) {
|
||||
let mut acc: f32 = row.iter().take(r + 1).sum();
|
||||
for (x, o) in out.iter_mut().enumerate() {
|
||||
*o = acc * norm;
|
||||
if let Some(v) = row.get(x + r + 1) {
|
||||
acc += v;
|
||||
}
|
||||
if let Some(v) = x.checked_sub(r).and_then(|i| row.get(i)) {
|
||||
acc -= v;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn transpose<T: Copy>(src: &[T], dst: &mut [T], w: usize, h: usize) {
|
||||
for (y, row) in src.chunks_exact(w).enumerate().take(h) {
|
||||
for (x, v) in row.iter().enumerate() {
|
||||
if let Some(d) = dst.get_mut(x * h + y) {
|
||||
*d = *v;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Gaussian-blur a `w` × `h` plane of values in place (three box passes per axis, zero past the
|
||||
/// edges). Does nothing when the sizes don't match.
|
||||
pub fn blur_plane(a: &mut Vec<f32>, w: usize, h: usize, sigma: f64) {
|
||||
if sigma < 0.2 || w == 0 || h == 0 || w.checked_mul(h) != Some(a.len()) {
|
||||
return;
|
||||
}
|
||||
let boxes = gauss_boxes(sigma);
|
||||
let mut tmp = vec![0.0; a.len()];
|
||||
for (len, other) in [(w, h), (h, w)] {
|
||||
for r in boxes {
|
||||
box_plane(a, &mut tmp, len, r);
|
||||
std::mem::swap(a, &mut tmp);
|
||||
}
|
||||
transpose(a, &mut tmp, len, other);
|
||||
std::mem::swap(a, &mut tmp);
|
||||
}
|
||||
}
|
||||
|
||||
/// Premultiplied RGBA at 16 bits (×257): blurs and averages that keep their tones.
|
||||
pub(super) type Px16 = [u16; 4];
|
||||
|
||||
pub(super) fn to16(px: &[[u8; 4]]) -> Vec<Px16> {
|
||||
px.iter().map(|p| p.map(|v| v as u16 * 257)).collect()
|
||||
}
|
||||
|
||||
pub(super) fn to8(v: u16) -> u8 {
|
||||
((v as u32 + 128) / 257).min(255) as u8
|
||||
}
|
||||
|
||||
fn add(acc: &mut [u64; 4], p: &Px16) {
|
||||
for (a, v) in acc.iter_mut().zip(p) {
|
||||
*a += *v as u64;
|
||||
}
|
||||
}
|
||||
|
||||
fn sub(acc: &mut [u64; 4], p: &Px16) {
|
||||
for (a, v) in acc.iter_mut().zip(p) {
|
||||
*a = a.saturating_sub(*v as u64);
|
||||
}
|
||||
}
|
||||
|
||||
fn mean(acc: &[u64; 4], n: u64) -> Px16 {
|
||||
acc.map(|a| ((a + n / 2) / n.max(1)).min(65535) as u16)
|
||||
}
|
||||
|
||||
/// One box blur of radius `r` along the rows of `w` pixels (transparent past the ends).
|
||||
fn box_rows(src: &[Px16], dst: &mut [Px16], w: usize, r: usize) {
|
||||
let n = 2 * r as u64 + 1;
|
||||
for (row, out) in src.chunks_exact(w).zip(dst.chunks_exact_mut(w)) {
|
||||
let mut acc = [0u64; 4];
|
||||
row.iter().take(r + 1).for_each(|p| add(&mut acc, p));
|
||||
for (x, o) in out.iter_mut().enumerate() {
|
||||
*o = mean(&acc, n);
|
||||
if let Some(p) = row.get(x + r + 1) {
|
||||
add(&mut acc, p);
|
||||
}
|
||||
if let Some(p) = x.checked_sub(r).and_then(|i| row.get(i)) {
|
||||
sub(&mut acc, p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One box blur of radius `r` down the columns of `w`-wide rows (transparent past the ends), row
|
||||
/// by row with a running sum per column.
|
||||
fn box_cols(src: &[Px16], dst: &mut [Px16], w: usize, r: usize) {
|
||||
let n = 2 * r as u64 + 1;
|
||||
let rows: Vec<&[Px16]> = src.chunks_exact(w).collect();
|
||||
let mut acc = vec![[0u64; 4]; w];
|
||||
let add_row = |acc: &mut [[u64; 4]], row: &[Px16]| acc.iter_mut().zip(row).for_each(|(a, p)| add(a, p));
|
||||
rows.iter().take(r + 1).for_each(|row| add_row(&mut acc, row));
|
||||
for (y, out) in dst.chunks_exact_mut(w).enumerate() {
|
||||
for (o, a) in out.iter_mut().zip(&acc) {
|
||||
*o = mean(a, n);
|
||||
}
|
||||
if let Some(row) = rows.get(y + r + 1) {
|
||||
add_row(&mut acc, row);
|
||||
}
|
||||
if let Some(row) = y.checked_sub(r).and_then(|i| rows.get(i)) {
|
||||
acc.iter_mut().zip(*row).for_each(|(a, p)| sub(a, p));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Box-blur `w`-wide rows of pixels in place with radius `r` along both axes.
|
||||
pub(super) fn box_blur(px: &mut [Px16], w: usize, r: usize) {
|
||||
if r == 0 || w == 0 {
|
||||
return;
|
||||
}
|
||||
let mut tmp = vec![[0u16; 4]; px.len()];
|
||||
box_rows(px, &mut tmp, w, r);
|
||||
box_cols(&tmp, px, w, r);
|
||||
}
|
||||
|
||||
/// Gaussian-blur `w`-wide rows of premultiplied pixels in place (σ = `sigma` pixels, three box
|
||||
/// passes per axis, transparent past the edges).
|
||||
pub(super) fn gaussian(px: &mut [Px16], w: usize, sigma: f64) {
|
||||
if sigma < 0.2 || w == 0 {
|
||||
return;
|
||||
}
|
||||
let mut tmp = vec![[0u16; 4]; px.len()];
|
||||
for r in gauss_boxes(sigma) {
|
||||
box_rows(px, &mut tmp, w, r);
|
||||
px.copy_from_slice(&tmp);
|
||||
}
|
||||
for r in gauss_boxes(sigma) {
|
||||
box_cols(px, &mut tmp, w, r);
|
||||
px.copy_from_slice(&tmp);
|
||||
}
|
||||
}
|
||||
|
||||
/// Gaussian-blur `data`, `w` × `h` premultiplied RGBA8 pixels, in place (σ = `sigma` pixels). Does
|
||||
/// nothing when the sizes don't match.
|
||||
pub fn gaussian_rgba(data: &mut [u8], w: usize, h: usize, sigma: f64) {
|
||||
if let Some(px) = pixels(data, w, h)
|
||||
&& sigma >= 0.2
|
||||
{
|
||||
let mut wide = to16(px);
|
||||
gaussian(&mut wide, w, sigma);
|
||||
for (p, q) in px.iter_mut().zip(&wide) {
|
||||
*p = q.map(to8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,31 @@
|
||||
//! Sharpen › Unsharp Mask.
|
||||
|
||||
use super::{gaussian, to16};
|
||||
|
||||
/// Unsharp Mask: each colour channel pushed away from a Gaussian blur of the pixels (σ = `sigma`
|
||||
/// pixels) by `amount` times their difference, where that difference reaches `threshold` levels.
|
||||
/// The blur is premultiplied, so edges against transparency don't darken; coverage stays as it is.
|
||||
pub(super) fn unsharp(px: &mut [[u8; 4]], w: usize, h: usize, amount: f64, sigma: f64, threshold: f64) {
|
||||
if sigma.is_nan() || sigma < 0.2 || amount.is_nan() || amount <= 0.0 || h == 0 {
|
||||
return;
|
||||
}
|
||||
let mut blurred = to16(px);
|
||||
gaussian(&mut blurred, w, sigma);
|
||||
let (amount, threshold) = (amount as f32, threshold as f32);
|
||||
for (p, b) in px.iter_mut().zip(&blurred) {
|
||||
let a = p[3] as f32;
|
||||
if a == 0.0 {
|
||||
continue;
|
||||
}
|
||||
for i in 0..3 {
|
||||
let own = p[i] as f32 * 255.0 / a;
|
||||
let around = if b[3] > 0 { b[i] as f32 * 255.0 / b[3] as f32 } else { own };
|
||||
let diff = own - around;
|
||||
if diff.abs() < threshold || diff == 0.0 {
|
||||
continue;
|
||||
}
|
||||
let v = (own + amount * diff).clamp(0.0, 255.0);
|
||||
p[i] = (v * a / 255.0).round().clamp(0.0, 255.0) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::Effect;
|
||||
use vectorcraft_geom::{Affine, Point, Rect};
|
||||
|
||||
use super::*;
|
||||
use crate::{RasterFx, raster_effects};
|
||||
|
||||
/// A `w` × `h` raster whose pixels are document points (origin top-left).
|
||||
fn space(w: usize, h: usize) -> PixelSpace {
|
||||
PixelSpace { to_doc: Affine::IDENTITY, px: 1.0, center: Point::new(w as f64 / 2.0, h as f64 / 2.0) }
|
||||
}
|
||||
|
||||
fn image(w: usize, h: usize, f: impl Fn(usize, usize) -> [u8; 4]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(w * h * 4);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
out.extend(f(x, y));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn at(d: &[u8], w: usize, x: usize, y: usize) -> [u8; 4] {
|
||||
let i = (y * w + x) * 4;
|
||||
[d[i], d[i + 1], d[i + 2], d[i + 3]]
|
||||
}
|
||||
|
||||
fn fx(id: &str, p: serde_json::Value) -> PixelFx {
|
||||
let e = Effect { id: id.into(), params: p, visible: true };
|
||||
match raster_effects(&[e]).first() {
|
||||
Some(RasterFx::Pixel(p)) => *p,
|
||||
other => panic!("{id}: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn grey(v: u8) -> [u8; 4] {
|
||||
[v, v, v, 255]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_take_defaults_and_stay_in_range() {
|
||||
assert_eq!(fx("blur.radial", json!({})), PixelFx::RadialBlur { amount: 10.0, zoom: false, passes: 6 });
|
||||
assert_eq!(
|
||||
fx("blur.radial", json!({"amount": 1e308, "method": "ZOOM", "quality": "best"})),
|
||||
PixelFx::RadialBlur { amount: 100.0, zoom: true, passes: 8 }
|
||||
);
|
||||
assert_eq!(fx("blur.radial", json!({"amount": -5, "quality": "nonsense"})), PixelFx::RadialBlur { amount: 1.0, zoom: false, passes: 6 });
|
||||
assert_eq!(fx("blur.smart", json!({})), PixelFx::SmartBlur { radius: 3.0, threshold: 25.0, samples: 7 });
|
||||
assert_eq!(
|
||||
fx("blur.smart", json!({"radius": "1e999", "threshold": -1, "quality": "low"})),
|
||||
PixelFx::SmartBlur { radius: 3.0, threshold: 0.1, samples: 5 }
|
||||
);
|
||||
assert_eq!(fx("sharpen.unsharpMask", json!({})), PixelFx::UnsharpMask { amount: 0.5, radius: 1.0, threshold: 0.0 });
|
||||
assert_eq!(
|
||||
fx("sharpen.unsharpMask", json!({"amount": 1e9, "radius": 0, "threshold": 1e9})),
|
||||
PixelFx::UnsharpMask { amount: 5.0, radius: 0.1, threshold: 255.0 }
|
||||
);
|
||||
for id in PIXEL_EFFECTS {
|
||||
assert!(crate::is_raster(id) && crate::effect_info(id).is_some_and(|e| e.raster), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outsets_follow_the_object() {
|
||||
let b = Rect::new(0.0, 0.0, 100.0, 50.0);
|
||||
let spin = fx("blur.radial", json!({}));
|
||||
let far = 0.5 * 100f64.hypot(50.0);
|
||||
assert!((spin.outset(b) - (far - 25.0)).abs() < 1e-9);
|
||||
assert!(fx("blur.radial", json!({"method": "zoom", "amount": 100})).outset(b) > 0.4 * far);
|
||||
assert_eq!(fx("blur.smart", json!({"radius": 4})).outset(b), 4.0);
|
||||
assert_eq!(fx("sharpen.unsharpMask", json!({})).outset(b), 0.0);
|
||||
assert_eq!(spin.reach(), None);
|
||||
assert_eq!(fx("sharpen.unsharpMask", json!({"radius": 2})).reach(), Some(6.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_sizes_and_empty_rasters_are_left_alone() {
|
||||
for id in PIXEL_EFFECTS {
|
||||
let f = fx(id, json!({}));
|
||||
let mut d = vec![200u8; 4 * 10];
|
||||
f.apply(&mut d, 3, 3, &space(3, 3));
|
||||
f.apply(&mut d, 0, 10, &space(0, 10));
|
||||
f.apply(&mut [], 0, 0, &space(0, 0));
|
||||
assert!(d.iter().all(|v| *v == 200), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extreme_parameters_finish() {
|
||||
let (w, h) = (40, 30);
|
||||
let src = image(w, h, |x, y| if (x / 5 + y / 5) % 2 == 0 { grey(250) } else { [0, 0, 0, 0] });
|
||||
let tiny = PixelSpace { px: 1e-9, ..space(w, h) };
|
||||
let huge = PixelSpace { px: 1e9, center: Point::new(f64::MAX, -1e300), ..space(w, h) };
|
||||
for id in PIXEL_EFFECTS {
|
||||
for p in [json!({"amount": 1e308, "radius": 1e308, "threshold": 1e308, "quality": "best"}), json!({"amount": 0, "radius": 0})] {
|
||||
for s in [tiny, huge, space(w, h)] {
|
||||
let mut d = src.clone();
|
||||
fx(id, p.clone()).apply(&mut d, w, h, &s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_effect_is_deterministic() {
|
||||
let (w, h) = (32, 24);
|
||||
let src = image(w, h, |x, y| {
|
||||
let a = if x > 4 { 255 } else { 128 };
|
||||
[((x * 8) as u8).min(a), ((y * 10) as u8).min(a), ((x * y % 256) as u8).min(a), a]
|
||||
});
|
||||
for id in PIXEL_EFFECTS {
|
||||
let f = fx(id, json!({"amount": 40, "radius": 3, "threshold": 40}));
|
||||
let (mut a, mut b) = (src.clone(), src.clone());
|
||||
f.apply(&mut a, w, h, &space(w, h));
|
||||
f.apply(&mut b, w, h, &space(w, h));
|
||||
assert_eq!(a, b, "{id}");
|
||||
assert_ne!(a, src, "{id} changes the image");
|
||||
// Premultiplied stays premultiplied.
|
||||
assert!(a.chunks_exact(4).all(|p| p[0] <= p[3] && p[1] <= p[3] && p[2] <= p[3]), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spin_blur_smears_along_arcs_and_keeps_the_centre() {
|
||||
let (w, h) = (41, 41);
|
||||
// A white dot 12 px right of the centre (20.5, 20.5), and one on the centre.
|
||||
let src = image(w, h, |x, y| if (x == 32 || x == 20) && y == 20 { grey(255) } else { [0, 0, 0, 0] });
|
||||
let mut d = src.clone();
|
||||
fx("blur.radial", json!({"amount": 60, "quality": "best"})).apply(&mut d, w, h, &space(w, h));
|
||||
// 60° of arc around the centre: up to 30° either way, 12 px out: (20.5 + 12 cos 20°, 20.5 ± 12 sin 20°).
|
||||
assert!(at(&d, w, 31, 16)[3] > 0 && at(&d, w, 31, 24)[3] > 0, "the dot spreads along its circle");
|
||||
assert_eq!(at(&d, w, 32, 10)[3], 0, "not beyond the arc");
|
||||
assert_eq!(at(&d, w, 26, 20)[3], 0, "not towards the centre");
|
||||
assert!(at(&d, w, 20, 20)[3] > 100, "the centre barely moves: {:?}", at(&d, w, 20, 20));
|
||||
// Zoom smears along the ray instead.
|
||||
let mut z = src.clone();
|
||||
fx("blur.radial", json!({"amount": 60, "method": "zoom", "quality": "best"})).apply(&mut z, w, h, &space(w, h));
|
||||
assert!(at(&z, w, 35, 20)[3] > 0 && at(&z, w, 29, 20)[3] > 0, "the dot streaks along the ray");
|
||||
assert_eq!(at(&z, w, 32, 16)[3], 0, "not across it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smart_blur_smooths_noise_and_keeps_edges() {
|
||||
let (w, h) = (40, 20);
|
||||
// Left: grey 100 with ±6 noise; right: white.
|
||||
let src = image(w, h, |x, y| if x < 20 { grey(if (x + y) % 2 == 0 { 94 } else { 106 }) } else { grey(255) });
|
||||
let mut d = src.clone();
|
||||
fx("blur.smart", json!({"radius": 3, "threshold": 25})).apply(&mut d, w, h, &space(w, h));
|
||||
let v = at(&d, w, 8, 10)[0];
|
||||
assert!((97..=103).contains(&v), "noise smoothed: {v}");
|
||||
assert!(at(&d, w, 19, 10)[0] < 120, "the edge's dark side doesn't take the white");
|
||||
assert_eq!(at(&d, w, 20, 10)[0], 255, "the edge's white side stays white");
|
||||
// A threshold above the step blurs across it.
|
||||
let mut all = src.clone();
|
||||
fx("blur.smart", json!({"radius": 3, "threshold": 100})).apply(&mut all, w, h, &space(w, h));
|
||||
assert_eq!(at(&all, w, 20, 10)[0], 255, "a 155-level step is still past 100");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsharp_mask_overshoots_at_edges() {
|
||||
let (w, h) = (40, 10);
|
||||
let src = image(w, h, |x, _| grey(if x < 20 { 100 } else { 150 }));
|
||||
let mut d = src.clone();
|
||||
fx("sharpen.unsharpMask", json!({"amount": 100, "radius": 2})).apply(&mut d, w, h, &space(w, h));
|
||||
assert!(at(&d, w, 19, 5)[0] < 95, "dark side darker: {:?}", at(&d, w, 19, 5));
|
||||
assert!(at(&d, w, 20, 5)[0] > 155, "light side lighter: {:?}", at(&d, w, 20, 5));
|
||||
assert_eq!(at(&d, w, 5, 5), grey(100), "flat areas stay");
|
||||
assert_eq!(at(&d, w, 35, 5), grey(150));
|
||||
// Differences under the threshold are left alone.
|
||||
let mut t = src.clone();
|
||||
fx("sharpen.unsharpMask", json!({"amount": 100, "radius": 2, "threshold": 60})).apply(&mut t, w, h, &space(w, h));
|
||||
assert_eq!(t, src);
|
||||
// Coverage stays, and a shape's edge against transparency doesn't darken.
|
||||
let shape = image(w, h, |x, _| if x < 20 { [200, 0, 0, 255] } else { [0, 0, 0, 0] });
|
||||
let mut s = shape.clone();
|
||||
fx("sharpen.unsharpMask", json!({"amount": 300, "radius": 3})).apply(&mut s, w, h, &space(w, h));
|
||||
assert_eq!(s, shape);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lengths_are_document_units() {
|
||||
// The same art at twice the resolution, sharpened with the same radius in points: the
|
||||
// overshoot spans twice the pixels, the same distance.
|
||||
let edge = |scale: usize| image(40 * scale, 4, move |x, _| grey(if x < 20 * scale { 100 } else { 150 }));
|
||||
let run = |scale: usize| {
|
||||
let mut d = edge(scale);
|
||||
let s = PixelSpace { to_doc: Affine::scale(1.0 / scale as f64), px: 1.0 / scale as f64, center: Point::ZERO };
|
||||
fx("sharpen.unsharpMask", json!({"amount": 100, "radius": 2})).apply(&mut d, 40 * scale, 4, &s);
|
||||
(0..40 * scale).filter(|x| at(&d, 40 * scale, *x, 2)[0] < 99).count()
|
||||
};
|
||||
let (one, two) = (run(1), run(2));
|
||||
assert!(one > 1 && (two as f64 / one as f64 - 2.0).abs() < 0.5, "{one} px at 1×, {two} px at 2×");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gaussian_and_plane_blurs_keep_their_mass() {
|
||||
let (w, h) = (30, 30);
|
||||
let mut d = image(w, h, |x, y| if (10..20).contains(&x) && (10..20).contains(&y) { grey(255) } else { [0, 0, 0, 0] });
|
||||
let before: u32 = d.chunks_exact(4).map(|p| p[3] as u32).sum();
|
||||
gaussian_rgba(&mut d, w, h, 2.0);
|
||||
let after: u32 = d.chunks_exact(4).map(|p| p[3] as u32).sum();
|
||||
assert!((before as f64 - after as f64).abs() / (before as f64) < 0.02, "{before} → {after}");
|
||||
assert!(at(&d, w, 9, 15)[3] > 0 && at(&d, w, 10, 15)[3] < 255);
|
||||
let mut plane: Vec<f32> = (0..w * h).map(|i| if i == 15 * w + 15 { 100.0 } else { 0.0 }).collect();
|
||||
blur_plane(&mut plane, w, h, 2.0);
|
||||
assert!((plane.iter().sum::<f32>() - 100.0).abs() < 0.5 && plane[15 * w + 13] > 0.0 && plane[13 * w + 15] > 0.0);
|
||||
}
|
||||
|
||||
/// Timings on a 2000 × 2000 raster (`cargo test -p vectorcraft-effects --release -- --ignored
|
||||
/// --nocapture pixel_timings`).
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn pixel_timings() {
|
||||
let (w, h) = (2000, 2000);
|
||||
let src = image(w, h, |x, y| [(x % 256) as u8, (y % 256) as u8, ((x ^ y) % 256) as u8, 255]);
|
||||
let s = PixelSpace { to_doc: Affine::IDENTITY, px: 1.0, center: Point::new(1000.0, 1000.0) };
|
||||
let cases = [
|
||||
("blur.radial", json!({"quality": "draft"})),
|
||||
("blur.radial", json!({"quality": "good"})),
|
||||
("blur.radial", json!({"quality": "best", "method": "zoom"})),
|
||||
("blur.smart", json!({"quality": "low"})),
|
||||
("blur.smart", json!({"quality": "high", "radius": 30})),
|
||||
("sharpen.unsharpMask", json!({"radius": 20})),
|
||||
];
|
||||
for (id, p) in cases {
|
||||
let mut d = src.clone();
|
||||
let t = std::time::Instant::now();
|
||||
fx(id, p.clone()).apply(&mut d, w, h, &s);
|
||||
println!("{id} {p}: {:?}", t.elapsed());
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,10 @@ use serde_json::Value;
|
||||
use vectorcraft_doc::Effect;
|
||||
use vectorcraft_doc::color::{BlendMode, Color};
|
||||
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
use crate::merged_params;
|
||||
use crate::pixel::{self, PixelFx};
|
||||
use crate::util::*;
|
||||
|
||||
/// A painted effect, in document units. `blur` is Illustrator's blur distance; the renderer uses
|
||||
@@ -40,6 +43,8 @@ pub enum RasterFx {
|
||||
GaussianBlur {
|
||||
radius: f64,
|
||||
},
|
||||
/// A Photoshop-style filter over the object's pixels (Radial Blur, Unsharp Mask…).
|
||||
Pixel(PixelFx),
|
||||
}
|
||||
|
||||
impl RasterFx {
|
||||
@@ -47,13 +52,15 @@ impl RasterFx {
|
||||
pub fn is_below(&self) -> bool {
|
||||
matches!(self, RasterFx::DropShadow { .. } | RasterFx::OuterGlow { .. })
|
||||
}
|
||||
/// How far this effect reaches beyond the geometry.
|
||||
pub fn outset(&self) -> f64 {
|
||||
/// How far this effect reaches beyond `bounds`, what it paints around (Radial Blur reaches
|
||||
/// further around larger objects).
|
||||
pub fn outset(&self, bounds: Rect) -> f64 {
|
||||
match self {
|
||||
RasterFx::DropShadow { dx, dy, blur, .. } => dx.abs().max(dy.abs()) + 1.5 * blur,
|
||||
RasterFx::OuterGlow { blur, .. } => 1.5 * blur,
|
||||
RasterFx::InnerGlow { .. } | RasterFx::Feather { .. } => 0.0,
|
||||
RasterFx::GaussianBlur { radius } => 1.5 * radius,
|
||||
RasterFx::Pixel(p) => p.outset(bounds),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,14 +120,14 @@ pub fn raster_effects(effects: &[Effect]) -> Vec<RasterFx> {
|
||||
},
|
||||
"stylize.feather" => RasterFx::Feather { radius: num(&p, "radius", 5.0).clamp(0.0, 1000.0) },
|
||||
"blur.gaussian" => RasterFx::GaussianBlur { radius: num(&p, "radius", 5.0).clamp(0.0, 1000.0) },
|
||||
_ => return None,
|
||||
id => RasterFx::Pixel(pixel::parse(id, &p)?),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// How far the visible raster effects of `effects` paint beyond the (effected) geometry, in
|
||||
/// document units. Geometry effects are accounted for by evaluating them.
|
||||
pub fn outset(effects: &[Effect]) -> f64 {
|
||||
raster_effects(effects).iter().map(RasterFx::outset).fold(0.0, f64::max)
|
||||
/// How far the visible raster effects of `effects` paint beyond `bounds`, the (effected)
|
||||
/// geometry's, in document units. Geometry effects are accounted for by evaluating them.
|
||||
pub fn outset(effects: &[Effect], bounds: Rect) -> f64 {
|
||||
raster_effects(effects).iter().map(|fx| fx.outset(bounds)).fold(0.0, f64::max)
|
||||
}
|
||||
@@ -301,8 +301,8 @@ fn raster_effects_parse_and_outset() {
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
assert!(matches!(r[2], RasterFx::InnerGlow { center: true, .. }));
|
||||
assert!(close(outset(&list), 10.0 + 9.0, 1e-9));
|
||||
assert_eq!(outset(&[]), 0.0);
|
||||
assert!(close(outset(&list, vectorcraft_geom::Rect::ZERO), 10.0 + 9.0, 1e-9));
|
||||
assert_eq!(outset(&[], vectorcraft_geom::Rect::ZERO), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -25,6 +25,7 @@ vectorcraft-cad = { workspace = true }
|
||||
vectorcraft-eps = { workspace = true }
|
||||
vectorcraft-metafile = { workspace = true }
|
||||
vectorcraft-plugins = { workspace = true }
|
||||
vectorcraft-affinity = { workspace = true }
|
||||
image = { workspace = true }
|
||||
flate2 = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
@@ -34,6 +35,8 @@ thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
vectorcraft-affinity = { workspace = true, features = ["synth"] }
|
||||
crc32fast = { workspace = true }
|
||||
vectorcraft-testkit = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
wat = { workspace = true }
|
||||
@@ -170,8 +170,10 @@ impl Session {
|
||||
return None;
|
||||
}
|
||||
let d = self.clipboard.to_document();
|
||||
// SVG has no filters for the Photoshop-style effects: their objects go in as images.
|
||||
let flat = super::rasterfx::flatten_pixel_effects(&d);
|
||||
Some(vectorcraft_svg::export(
|
||||
&d,
|
||||
flat.as_ref().unwrap_or(&d),
|
||||
&vectorcraft_svg::ExportOptions { artboard: None, object_ids: vectorcraft_svg::ObjectIds::Minimal, ..Default::default() },
|
||||
))
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Paste in Front",
|
||||
["Edit"],
|
||||
Some("Cmd+F"),
|
||||
"{swatchConflict?} paste in place just above the top selected object, or on top of the current layer when nothing is selected (resources and layers as edit.paste) → {ids, added, merged, renamed}",
|
||||
"{swatchConflict?, artboard?: index (the active artboard: the objects go where they were on the artboard they were copied from, on this one)} paste in place just above the top selected object, or on top of the current layer when nothing is selected (resources and layers as edit.paste) → {ids, added, merged, renamed}",
|
||||
has_clipboard,
|
||||
|s, p| paste(s, p, PasteMode::Front)
|
||||
),
|
||||
@@ -64,7 +64,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Paste in Back",
|
||||
["Edit"],
|
||||
Some("Cmd+B"),
|
||||
"{swatchConflict?} paste in place just below the bottom selected object, or at the bottom of the current layer when nothing is selected (resources and layers as edit.paste) → {ids, added, merged, renamed}",
|
||||
"{swatchConflict?, artboard?: index (as edit.pasteInFront)} paste in place just below the bottom selected object, or at the bottom of the current layer when nothing is selected (resources and layers as edit.paste) → {ids, added, merged, renamed}",
|
||||
has_clipboard,
|
||||
|s, p| paste(s, p, PasteMode::Back)
|
||||
),
|
||||
@@ -73,7 +73,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Paste in Place",
|
||||
["Edit"],
|
||||
Some("Cmd+Shift+V"),
|
||||
"{swatchConflict?} paste where the objects were copied (resources and layers as edit.paste) → {ids, added, merged, renamed}",
|
||||
"{swatchConflict?, artboard?: index (as edit.pasteInFront)} paste where the objects were copied (resources and layers as edit.paste) → {ids, added, merged, renamed}",
|
||||
has_clipboard,
|
||||
|s, p| paste(s, p, PasteMode::InPlace)
|
||||
),
|
||||
@@ -310,6 +310,17 @@ fn paste(s: &mut Session, p: &Value, mode: PasteMode) -> Result<Value> {
|
||||
r
|
||||
}
|
||||
|
||||
/// Paste in Place, in Front or in Back onto artboard `artboard` (the active one, #693): where the
|
||||
/// objects were on the artboard they were copied from, on that one; without it (or a copy from no
|
||||
/// artboard), where they were.
|
||||
fn onto_artboard(doc: &Document, p: &Value, clip: &Clipboard) -> Affine {
|
||||
let target = p.get("artboard").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()).and_then(|i| doc.artboards.get(i));
|
||||
match (target, clip.source_artboard) {
|
||||
(Some(t), Some(src)) => Affine::translate(t.rect.origin() - src.origin()),
|
||||
_ => Affine::IDENTITY,
|
||||
}
|
||||
}
|
||||
|
||||
fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, choices: &SwatchChoices) -> Result<Value> {
|
||||
let off = s.prefs.paste_offset;
|
||||
let st = s.doc()?;
|
||||
@@ -345,7 +356,8 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
|
||||
let src = clip.source_artboard.or_else(|| st.doc.artboards.first().map(|a| a.rect)).map(|r| r.origin()).unwrap_or_default();
|
||||
st.doc.artboards.iter().map(|a| Affine::translate(a.rect.origin() - src)).collect()
|
||||
}
|
||||
_ => vec![Affine::IDENTITY],
|
||||
// In place, in front, in back: onto the active artboard when the UI names it.
|
||||
_ => vec![onto_artboard(&st.doc, p, clip)],
|
||||
};
|
||||
let (ids, imported, artboard) = s.edit(mode.label(), |d, sel| {
|
||||
let imported = clip.import_into(d, choices, same_doc);
|
||||
|
||||
@@ -0,0 +1,509 @@
|
||||
//! Affinity documents (`.af`, `.afdesign`, `.afphoto`, `.afpub`): the native layers, curves,
|
||||
//! shapes, artboards, text and images that `vectorcraft-affinity` reads, mapped to editable
|
||||
//! VectorCraft art. Whatever the reader could not map is listed in the warnings. A file whose
|
||||
//! native document can't be read opens as its embedded preview instead, with a warning that says
|
||||
//! why, and can't be placed.
|
||||
|
||||
use std::io::Cursor;
|
||||
use std::sync::Arc;
|
||||
|
||||
use kurbo::{Affine, BezPath, Point, Rect, Vec2};
|
||||
use vectorcraft_affinity::model::{self as af, Align as AfAlign, Blend, Kind, Pixels};
|
||||
use vectorcraft_affinity::paint::{self as ap, Cap, GradientKind as AfKind, Join};
|
||||
use vectorcraft_color::{BlendMode, Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint};
|
||||
use vectorcraft_doc::appearance::{Appearance, AppearanceItem, Dash, FillLayer, LineCap, LineJoin, StrokeAlign, StrokeLayer};
|
||||
use vectorcraft_doc::geom::{FillRule, PathData, shapes};
|
||||
use vectorcraft_doc::text::{CharStyle, Justify, TextKind, TextObject, TextRun};
|
||||
use vectorcraft_doc::{Artboard, Document, ImageObject, LayerColor, Node, NodeKind, OpacityMask};
|
||||
|
||||
use super::load::{err, raster_doc};
|
||||
use crate::Result;
|
||||
|
||||
/// What an Affinity file opened as.
|
||||
pub(super) struct Imported {
|
||||
pub doc: Document,
|
||||
pub warnings: Vec<String>,
|
||||
/// Only the embedded preview: never placed, used as a template or mined for styles.
|
||||
pub preview_only: bool,
|
||||
}
|
||||
|
||||
pub(super) fn import(name: &str, bytes: &[u8]) -> Result<Imported> {
|
||||
match vectorcraft_affinity::read(bytes, vectorcraft_affinity::Limits::default()) {
|
||||
Ok(d) => {
|
||||
let mut b = Builder { doc: Document::new(0.0, 0.0), warnings: Vec::new(), k: 72.0 / d.dpi, layer: 0 };
|
||||
b.document(&d)?;
|
||||
let mut warnings = d.warnings.iter().map(|w| format!("Affinity: not imported or approximated: {w}.")).collect::<Vec<_>>();
|
||||
warnings.extend(b.warnings.iter().map(|w| format!("Affinity: {w}.")));
|
||||
Ok(Imported { doc: b.doc, warnings, preview_only: false })
|
||||
}
|
||||
Err(native) => {
|
||||
let (doc, w) = preview(name, bytes, &native)?;
|
||||
Ok(Imported { doc, warnings: vec![w], preview_only: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The embedded preview, for files whose native document can't be read.
|
||||
fn preview(name: &str, bytes: &[u8], native: &vectorcraft_affinity::Error) -> Result<(Document, String)> {
|
||||
let p = vectorcraft_affinity::preview(bytes).map_err(|e| err(format!("{native}; its embedded preview can't be read either ({e})")))?;
|
||||
// `raster_doc` normally probes PNG dimensions and keeps its encoded bytes. Verify this
|
||||
// untrusted preview's complete pixels first, so corrupt data cannot create a blank image.
|
||||
let mut reader = image::ImageReader::with_format(Cursor::new(p.png), image::ImageFormat::Png);
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_image_width = Some(4096);
|
||||
limits.max_image_height = Some(4096);
|
||||
limits.max_alloc = Some(128 << 20);
|
||||
reader.limits(limits);
|
||||
let _pixels = reader.decode().map_err(err)?;
|
||||
let d = raster_doc(name, p.png)?;
|
||||
Ok((
|
||||
d,
|
||||
format!(
|
||||
"The native Affinity document could not be read ({native}). Opened only its embedded {}×{} PNG preview, which may be smaller than the document: no layers, vectors, text or pages. Save a new copy; the Affinity source cannot be saved back. Export SVG or PDF from Affinity for editable vectors.",
|
||||
p.width, p.height
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
struct Builder {
|
||||
doc: Document,
|
||||
warnings: Vec<String>,
|
||||
/// Points per document pixel.
|
||||
k: f64,
|
||||
/// Layers made so far (their colours cycle).
|
||||
layer: u8,
|
||||
}
|
||||
|
||||
fn rect_of(r: af::Rect) -> Rect {
|
||||
Rect::new(r.x0, r.y0, r.x1, r.y1)
|
||||
}
|
||||
|
||||
fn affine(a: af::Affine) -> Affine {
|
||||
Affine::new(a.0)
|
||||
}
|
||||
|
||||
impl Builder {
|
||||
fn warn(&mut self, w: &str) {
|
||||
if !self.warnings.iter().any(|x| x == w) {
|
||||
self.warnings.push(w.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn document(&mut self, d: &af::Document) -> Result<()> {
|
||||
// Spreads (Publisher pages) sit side by side, 36 pt apart; a single spread keeps its origin.
|
||||
let mut layers = Vec::new();
|
||||
let mut artboards = Vec::new();
|
||||
let mut x = 0.0;
|
||||
for (i, spread) in d.spreads.iter().enumerate() {
|
||||
let shift = if i == 0 { 0.0 } else { x - spread.bounds.x0 * self.k };
|
||||
let to_doc = Affine::translate((shift, 0.0)) * Affine::scale(self.k);
|
||||
let before = artboards.len();
|
||||
let mut loose: Vec<Arc<Node>> = Vec::new();
|
||||
for n in &spread.nodes {
|
||||
let top = match &n.kind {
|
||||
Kind::Layer => self.node(n, to_doc, &mut artboards, true),
|
||||
Kind::Artboard { .. } => self.node(n, to_doc, &mut artboards, true).map(|art| self.layer_of(&n.name, art, n.visible, n.locked)),
|
||||
_ => {
|
||||
if let Some(art) = self.node(n, to_doc, &mut artboards, false) {
|
||||
loose.push(art);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if !loose.is_empty() {
|
||||
layers.push(self.layer_with(None, std::mem::take(&mut loose)));
|
||||
}
|
||||
layers.extend(top);
|
||||
}
|
||||
if !loose.is_empty() {
|
||||
layers.push(self.layer_with(None, loose));
|
||||
}
|
||||
// Without artboards, each page (or the spread) becomes one.
|
||||
if artboards.len() == before {
|
||||
let rects = if spread.pages.is_empty() { vec![spread.bounds] } else { spread.pages.clone() };
|
||||
for r in rects {
|
||||
let name = format!("{} {}", if d.spreads.len() > 1 || spread.pages.len() > 1 { "Page" } else { "Artboard" }, artboards.len() + 1);
|
||||
artboards.push((name, to_doc.transform_rect_bbox(rect_of(r))));
|
||||
}
|
||||
}
|
||||
x = artboards.iter().map(|(_, r)| r.x1).fold(x, f64::max) + 36.0;
|
||||
if spread.transparent && i == 0 {
|
||||
self.warn("transparent page backgrounds show as white artboards");
|
||||
}
|
||||
}
|
||||
self.doc.artboards = artboards
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.filter(|(_, (_, r))| r.width() > 0.0 && r.height() > 0.0)
|
||||
.map(|(i, (name, rect))| Artboard { id: i as u32 + 1, name, rect, show_center_mark: false, show_cross_hairs: false })
|
||||
.collect();
|
||||
if self.doc.artboards.is_empty() {
|
||||
return Err(err("the Affinity document has no page with a size"));
|
||||
}
|
||||
// Bottom to top, as Affinity lists them.
|
||||
self.doc.layers = layers;
|
||||
if self.doc.layers.is_empty() {
|
||||
let empty = self.layer_with(None, Vec::new());
|
||||
self.doc.layers.push(empty);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn layer_with(&mut self, name: Option<&str>, children: Vec<Arc<Node>>) -> Arc<Node> {
|
||||
let id = self.doc.alloc_id();
|
||||
self.layer = self.layer.wrapping_add(1);
|
||||
let label = name.map(str::to_string).unwrap_or_else(|| format!("Layer {}", self.layer));
|
||||
let mut n = Node::layer(id, &label, LayerColor::Preset(self.layer % 9));
|
||||
if let Some(c) = n.children_mut() {
|
||||
*c = children;
|
||||
}
|
||||
Arc::new(n)
|
||||
}
|
||||
|
||||
/// An artboard at the top level becomes a layer of its name holding its clip group.
|
||||
fn layer_of(&mut self, name: &str, art: Arc<Node>, visible: bool, locked: bool) -> Arc<Node> {
|
||||
let mut l = (*self.layer_with(Some(if name.is_empty() { "Artboard" } else { name }), vec![art])).clone();
|
||||
l.visible = visible;
|
||||
l.locked = locked;
|
||||
Arc::new(l)
|
||||
}
|
||||
|
||||
fn base(&mut self, n: &af::Node, kind: NodeKind) -> Node {
|
||||
let mut out = Node::new(self.doc.alloc_id(), kind);
|
||||
out.name = (!n.name.is_empty()).then(|| n.name.clone());
|
||||
out.visible = n.visible;
|
||||
out.locked = n.locked;
|
||||
out.opacity = n.opacity as f32;
|
||||
out.blend = self.blend(n.blend);
|
||||
out
|
||||
}
|
||||
|
||||
fn blend(&mut self, b: Blend) -> BlendMode {
|
||||
match b {
|
||||
Blend::Normal | Blend::PassThrough => BlendMode::Normal,
|
||||
Blend::Darken => BlendMode::Darken,
|
||||
Blend::Multiply => BlendMode::Multiply,
|
||||
Blend::ColorBurn => BlendMode::ColorBurn,
|
||||
Blend::Lighten => BlendMode::Lighten,
|
||||
Blend::Screen => BlendMode::Screen,
|
||||
Blend::ColorDodge => BlendMode::ColorDodge,
|
||||
Blend::Overlay => BlendMode::Overlay,
|
||||
Blend::SoftLight => BlendMode::SoftLight,
|
||||
Blend::HardLight => BlendMode::HardLight,
|
||||
Blend::Difference => BlendMode::Difference,
|
||||
Blend::Exclusion => BlendMode::Exclusion,
|
||||
Blend::Hue => BlendMode::Hue,
|
||||
Blend::Saturation => BlendMode::Saturation,
|
||||
Blend::Color => BlendMode::Color,
|
||||
Blend::Luminosity => BlendMode::Luminosity,
|
||||
_ => {
|
||||
self.warn("blend modes VectorCraft doesn't have (Add, Linear Light, Vivid Light, Pin Light, Hard Mix, Subtract, Darker/Lighter Colour, Average, Negation, Reflect, Glow, Erase) import as Normal");
|
||||
BlendMode::Normal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One Affinity node (and its subtree) as VectorCraft art; `to_doc` maps document pixels to
|
||||
/// points. `layer_level`: the node is directly in a spread or a layer, so a layer stays a layer.
|
||||
fn node(&mut self, n: &af::Node, to_doc: Affine, boards: &mut Vec<(String, Rect)>, layer_level: bool) -> Option<Arc<Node>> {
|
||||
let child_level = matches!(n.kind, Kind::Layer) && layer_level;
|
||||
let children: Vec<Arc<Node>> = n.children.iter().filter_map(|c| self.node(c, to_doc, boards, child_level)).collect();
|
||||
let mut out = match &n.kind {
|
||||
Kind::Layer if layer_level => {
|
||||
let mut l = (*self.layer_with(Some(if n.name.is_empty() { "Layer" } else { &n.name }), children)).clone();
|
||||
l.visible = n.visible;
|
||||
l.locked = n.locked;
|
||||
l.opacity = n.opacity as f32;
|
||||
l.blend = self.blend(n.blend);
|
||||
l
|
||||
}
|
||||
Kind::Layer | Kind::Group => {
|
||||
let mut g = self.base(n, NodeKind::Group { children, clip: false });
|
||||
// Affinity groups isolate their blending unless they pass through.
|
||||
g.isolate = n.blend != Blend::PassThrough;
|
||||
g
|
||||
}
|
||||
Kind::Shape { path, fills, strokes, even_odd } => {
|
||||
let data = self.path(path, to_doc);
|
||||
let rule = if *even_odd { FillRule::EvenOdd } else { FillRule::NonZero };
|
||||
let appearance = self.appearance(fills, strokes, to_doc);
|
||||
if children.is_empty() {
|
||||
let mut p = self.base(n, NodeKind::Path { path: data, rule, live: None, clipping: false, guide: false });
|
||||
p.appearance = appearance;
|
||||
p
|
||||
} else {
|
||||
// A shape clips its children: VectorCraft's clipping path keeps its paint, its
|
||||
// fill behind the clipped art and its stroke over it, as Affinity draws it.
|
||||
let mut clip = Node::new(self.doc.alloc_id(), NodeKind::Path { path: data, rule, live: None, clipping: true, guide: false });
|
||||
clip.appearance = appearance;
|
||||
let mut kids = vec![Arc::new(clip)];
|
||||
kids.extend(children);
|
||||
self.base(n, NodeKind::Group { children: kids, clip: true })
|
||||
}
|
||||
}
|
||||
Kind::Artboard { rect, background } => {
|
||||
let r = to_doc.transform_rect_bbox(rect_of(*rect));
|
||||
boards.push((if n.name.is_empty() { format!("Artboard {}", boards.len() + 1) } else { n.name.clone() }, r));
|
||||
let mut clip = Node::new(
|
||||
self.doc.alloc_id(),
|
||||
NodeKind::Path { path: shapes::rectangle(r), rule: FillRule::NonZero, live: None, clipping: true, guide: false },
|
||||
);
|
||||
clip.appearance = self.appearance(background, &[], to_doc);
|
||||
let mut kids = vec![Arc::new(clip)];
|
||||
kids.extend(children);
|
||||
let mut g = self.base(n, NodeKind::Group { children: kids, clip: true });
|
||||
g.name = Some(if n.name.is_empty() { "Artboard".into() } else { n.name.clone() });
|
||||
g
|
||||
}
|
||||
Kind::Text(t) => {
|
||||
let text = self.text(t, to_doc);
|
||||
let mut node = self.base(n, NodeKind::Text(Box::new(text)));
|
||||
if !children.is_empty() {
|
||||
let mut kids = vec![Arc::new(node)];
|
||||
kids.extend(children);
|
||||
node = Node::group(self.doc.alloc_id(), kids);
|
||||
}
|
||||
node
|
||||
}
|
||||
Kind::Image(img) => match self.image(img, to_doc) {
|
||||
Some(kind) => self.base(n, kind),
|
||||
None if children.is_empty() => return None,
|
||||
None => self.base(n, NodeKind::Group { children, clip: false }),
|
||||
},
|
||||
Kind::Unsupported if children.is_empty() => return None,
|
||||
Kind::Unsupported => self.base(n, NodeKind::Group { children, clip: false }),
|
||||
};
|
||||
if let Some(img) = &n.pixel_mask {
|
||||
// A pixel mask: its grey levels show (white) or hide (black) the node, and nothing
|
||||
// shows outside it, as an opacity mask that clips.
|
||||
if let Some(kind) = self.image(img, to_doc) {
|
||||
let art = Node::new(self.doc.alloc_id(), kind);
|
||||
out.mask = Some(Box::new(OpacityMask::new(art, true)));
|
||||
}
|
||||
}
|
||||
if let Some(mask) = &n.mask {
|
||||
// A vector mask: the node shows only inside it.
|
||||
let clip = Node::new(
|
||||
self.doc.alloc_id(),
|
||||
NodeKind::Path { path: self.path(mask, to_doc), rule: FillRule::NonZero, live: None, clipping: true, guide: false },
|
||||
);
|
||||
let (opacity, blend, visible, name) = (out.opacity, out.blend, out.visible, out.name.clone());
|
||||
out.opacity = 1.0;
|
||||
out.blend = BlendMode::Normal;
|
||||
let mut g = Node::new(self.doc.alloc_id(), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(out)], clip: true });
|
||||
(g.opacity, g.blend, g.visible, g.name) = (opacity, blend, visible, name);
|
||||
out = g;
|
||||
}
|
||||
Some(Arc::new(out))
|
||||
}
|
||||
|
||||
fn path(&self, p: &af::Path, to_doc: Affine) -> PathData {
|
||||
let mut bp = BezPath::new();
|
||||
for s in &p.subpaths {
|
||||
let pt = |q: af::Point| Point::new(q.x, q.y);
|
||||
bp.move_to(pt(s.start));
|
||||
for [c1, c2, e] in &s.segments {
|
||||
if c1 == c2 && c2 == e {
|
||||
bp.line_to(pt(*e));
|
||||
} else {
|
||||
bp.curve_to(pt(*c1), pt(*c2), pt(*e));
|
||||
}
|
||||
}
|
||||
if s.closed {
|
||||
bp.close_path();
|
||||
}
|
||||
}
|
||||
PathData::from_bezpath(&(to_doc * bp))
|
||||
}
|
||||
|
||||
fn color(c: ap::Color) -> (Color, f32) {
|
||||
let f = |v: f64| v.clamp(0.0, 1.0) as f32;
|
||||
match c {
|
||||
ap::Color::Rgb { r, g, b, a } => (Color::Rgb { r: f(r), g: f(g), b: f(b) }, f(a)),
|
||||
ap::Color::Cmyk { c, m, y, k, a } => (Color::Cmyk { c: f(c), m: f(m), y: f(y), k: f(k) }, f(a)),
|
||||
ap::Color::Gray { v, a } => (Color::Gray { k: 1.0 - f(v) }, f(a)),
|
||||
ap::Color::Lab { l, a, b, alpha } => (Color::Lab { l: l as f32, a: a as f32, b: b as f32 }, f(alpha)),
|
||||
}
|
||||
}
|
||||
|
||||
/// A paint and the opacity its colour's alpha becomes.
|
||||
fn paint(&mut self, p: &ap::Paint, to_doc: Affine) -> Option<(Paint, f32)> {
|
||||
match p {
|
||||
ap::Paint::None => None,
|
||||
ap::Paint::Solid(c) => {
|
||||
let (color, a) = Self::color(*c);
|
||||
Some((Paint::solid(color), a))
|
||||
}
|
||||
ap::Paint::Gradient(g) => {
|
||||
let stops = g
|
||||
.stops
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let (color, opacity) = Self::color(s.color);
|
||||
let mut stop = GradientStop::new(s.offset as f32, color);
|
||||
stop.opacity = opacity;
|
||||
stop.midpoint = s.half_point().clamp(0.13, 0.87) as f32;
|
||||
stop
|
||||
})
|
||||
.collect();
|
||||
let m = to_doc * affine(g.transform);
|
||||
let (o, x, y) = (m * Point::ZERO, m * Point::new(1.0, 0.0), m * Point::new(0.0, 1.0));
|
||||
let kind = match g.kind {
|
||||
AfKind::Linear => GradientKind::Linear,
|
||||
AfKind::Radial => GradientKind::Radial,
|
||||
AfKind::Conical => {
|
||||
self.warn("conical gradients import as radial gradients");
|
||||
GradientKind::Radial
|
||||
}
|
||||
};
|
||||
let (u, v): (Vec2, Vec2) = (x - o, y - o);
|
||||
if kind == GradientKind::Radial && u.hypot() > 0.0 && (u.dot(v) / (u.hypot() * v.hypot().max(1e-12))).abs() > 0.01 {
|
||||
self.warn("skewed elliptical gradients import as upright ellipses");
|
||||
}
|
||||
let aspect = if u.hypot() > 0.0 { (v.hypot() / u.hypot()).clamp(0.01, 100.0) } else { 1.0 };
|
||||
let geom = GradientGeom { start: o, end: x, aspect: if kind == GradientKind::Radial { aspect } else { 1.0 }, focal: None };
|
||||
let mut gp = GradientPaint::new(Gradient { kind, stops });
|
||||
gp.angle = (-(u.y)).atan2(u.x).to_degrees();
|
||||
gp.geom = Some(geom);
|
||||
Some((Paint::Gradient(Box::new(gp)), 1.0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn appearance(&mut self, fills: &[ap::Paint], strokes: &[ap::Stroke], to_doc: Affine) -> Appearance {
|
||||
let mut items = Vec::new();
|
||||
let mut above = Vec::new();
|
||||
for f in fills {
|
||||
if let Some((paint, opacity)) = self.paint(f, to_doc) {
|
||||
let mut l = FillLayer::new(paint);
|
||||
l.opacity = opacity;
|
||||
items.push(AppearanceItem::Fill(l));
|
||||
}
|
||||
}
|
||||
for s in strokes {
|
||||
let Some((paint, opacity)) = self.paint(&s.paint, to_doc) else { continue };
|
||||
let mut l = StrokeLayer::new(paint, s.width * self.k);
|
||||
l.opacity = opacity;
|
||||
l.cap = match s.cap {
|
||||
Cap::Butt => LineCap::Butt,
|
||||
Cap::Round => LineCap::Round,
|
||||
Cap::Square => LineCap::Square,
|
||||
};
|
||||
l.join = match s.join {
|
||||
Join::Miter => LineJoin::Miter,
|
||||
Join::Round => LineJoin::Round,
|
||||
Join::Bevel => LineJoin::Bevel,
|
||||
};
|
||||
l.miter_limit = s.miter_limit;
|
||||
l.align = match s.align {
|
||||
ap::Align::Center => StrokeAlign::Center,
|
||||
ap::Align::Inside => StrokeAlign::Inside,
|
||||
ap::Align::Outside => StrokeAlign::Outside,
|
||||
};
|
||||
l.dash = s.dash.as_ref().map(|(pattern, phase)| Dash {
|
||||
pattern: pattern.iter().map(|v| v * self.k).collect(),
|
||||
offset: phase * self.k,
|
||||
align_corners: false,
|
||||
});
|
||||
if s.behind {
|
||||
items.insert(0, AppearanceItem::Stroke(l));
|
||||
} else {
|
||||
above.push(AppearanceItem::Stroke(l));
|
||||
}
|
||||
}
|
||||
items.extend(above);
|
||||
Appearance { items, ..Appearance::default() }
|
||||
}
|
||||
|
||||
fn text(&mut self, t: &af::Text, to_doc: Affine) -> TextObject {
|
||||
let origin = match t.frame {
|
||||
Some(f) => Point::new(f.x0, f.y0),
|
||||
None => Point::new(t.anchor.x, t.anchor.y),
|
||||
};
|
||||
// Text space: origin at the anchor (or frame corner), the transform's uniform scale moved
|
||||
// into the type size so sizes read in points.
|
||||
let m = to_doc * affine(t.transform) * Affine::translate(origin.to_vec2());
|
||||
let c = m.as_coeffs();
|
||||
let k = (c[0] * c[3] - c[1] * c[2]).abs().sqrt().max(1e-9);
|
||||
let xf = m * Affine::scale(1.0 / k);
|
||||
let mut obj = TextObject::point(Point::ZERO, "", CharStyle::default());
|
||||
obj.xf = xf;
|
||||
obj.runs = t
|
||||
.runs
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let mut style =
|
||||
CharStyle { font_family: if r.family.is_empty() { r.postscript.clone() } else { r.family.clone() }, ..CharStyle::default() };
|
||||
style.font_style = font_style(r.weight, r.italic);
|
||||
style.size = r.size * k;
|
||||
style.leading = r.leading.map(|l| l * k);
|
||||
style.tracking = r.tracking * 1000.0;
|
||||
style.fill = match self.paint(&r.fill, to_doc) {
|
||||
Some((p, a)) => {
|
||||
if a < 0.999 {
|
||||
self.warn("translucent text colours import opaque");
|
||||
}
|
||||
p
|
||||
}
|
||||
None => Paint::None,
|
||||
};
|
||||
// Affinity separates paragraphs with U+2029 and lines with U+2028.
|
||||
TextRun::new(r.text.replace(['\u{2029}', '\u{2028}'], "\n"), style)
|
||||
})
|
||||
.collect();
|
||||
obj.para.justify = match t.align {
|
||||
AfAlign::Left => Justify::Left,
|
||||
AfAlign::Center => Justify::Center,
|
||||
AfAlign::Right => Justify::Right,
|
||||
AfAlign::Justify => Justify::JustifyLeft,
|
||||
};
|
||||
if let Some(f) = t.frame {
|
||||
obj.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, (f.x1 - f.x0) * k, (f.y1 - f.y0) * k)) };
|
||||
}
|
||||
obj
|
||||
}
|
||||
|
||||
fn image(&mut self, img: &af::Image, to_doc: Affine) -> Option<NodeKind> {
|
||||
let bytes = match &img.pixels {
|
||||
Pixels::Encoded(b) => b.clone(),
|
||||
Pixels::Rgba8(rgba) => {
|
||||
let buf = image::RgbaImage::from_raw(img.width, img.height, rgba.clone())?;
|
||||
let mut png = Vec::new();
|
||||
buf.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).ok()?;
|
||||
png
|
||||
}
|
||||
};
|
||||
let r = match super::load::raster_image(&bytes) {
|
||||
Ok(r) => r,
|
||||
Err(_) => {
|
||||
self.warn("an embedded image that could not be decoded was left out");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
// The pixel grid maps onto the Affinity image's own size, whatever the stored file's.
|
||||
let sx = img.width as f64 / f64::from(r.width.max(1));
|
||||
let sy = img.height as f64 / f64::from(r.height.max(1));
|
||||
let xf = to_doc * affine(img.transform) * Affine::scale_non_uniform(sx, sy);
|
||||
self.doc.images.entry(r.key.clone()).or_insert(r.blob);
|
||||
Some(NodeKind::Image(ImageObject { key: r.key, width: r.width, height: r.height, xf, link: None, placement: Default::default() }))
|
||||
}
|
||||
}
|
||||
|
||||
fn font_style(weight: i64, italic: bool) -> String {
|
||||
let w = match weight {
|
||||
..=150 => "Thin",
|
||||
151..=250 => "ExtraLight",
|
||||
251..=325 => "Light",
|
||||
326..=450 => "Regular",
|
||||
451..=550 => "Medium",
|
||||
551..=650 => "SemiBold",
|
||||
651..=750 => "Bold",
|
||||
751..=850 => "ExtraBold",
|
||||
_ => "Black",
|
||||
};
|
||||
match (w, italic) {
|
||||
("Regular", true) => "Italic".into(),
|
||||
(w, true) => format!("{w} Italic"),
|
||||
(w, false) => w.into(),
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,9 @@ pub struct Loaded {
|
||||
/// An older native file (the former `.drawcraft` name or format version): saving it again
|
||||
/// rewrites it in today's format.
|
||||
pub converted: bool,
|
||||
/// Only a stand-in picture of the file (an Affinity document whose native data couldn't be
|
||||
/// read): Open shows it with its warning; Place, templates and libraries refuse it.
|
||||
pub preview_only: bool,
|
||||
}
|
||||
|
||||
/// An image ready to embed: PNG/JPEG/GIF/WebP keep their bytes, other formats are stored as PNG
|
||||
@@ -48,6 +51,9 @@ pub fn file_name(name: &str) -> String {
|
||||
/// extension of `name`.
|
||||
pub fn detect(name: &str, bytes: &[u8]) -> Option<&'static Format> {
|
||||
let by_name = format_for_name(name).filter(|f| f.read);
|
||||
if vectorcraft_affinity::is_affinity(bytes) {
|
||||
return format("affinity");
|
||||
}
|
||||
if vectorcraft_format::sniff(bytes) {
|
||||
// A template keeps its meaning from the extension, like .ait.
|
||||
return by_name.filter(|f| f.id == "template").or_else(|| format("vectorcraft"));
|
||||
@@ -135,6 +141,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
})?;
|
||||
let title = file_name(name);
|
||||
let mut converted = false;
|
||||
let mut preview_only = false;
|
||||
let (mut doc, warnings, restored) = match format.id {
|
||||
// EPS and PostScript .ai: the document our EPS files carry, else the PostScript read.
|
||||
_ if format.id == "eps" || vectorcraft_pdf::is_postscript(bytes) => {
|
||||
@@ -174,6 +181,11 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
let (doc, warnings) = super::metafile::import(bytes)?;
|
||||
(doc, warnings, false)
|
||||
}
|
||||
"affinity" => {
|
||||
let i = super::affinity::import(&title, bytes)?;
|
||||
preview_only = i.preview_only;
|
||||
(i.doc, i.warnings, false)
|
||||
}
|
||||
_ if format.raster => (raster_doc(&title, bytes)?, vec![], false),
|
||||
_ => return Err(err(format!("{} files can't be opened yet", format.label))),
|
||||
};
|
||||
@@ -185,7 +197,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
if !matches!(format.id, "vectorcraft" | "template") || doc.title.is_empty() {
|
||||
doc.title = title;
|
||||
}
|
||||
Ok(Loaded { doc, format, warnings, restored, converted })
|
||||
Ok(Loaded { doc, format, warnings, restored, converted, preview_only })
|
||||
}
|
||||
|
||||
/// Import SVG text, reading the files its images link to (relative links from `folder`, the SVG's
|
||||
@@ -220,12 +232,18 @@ pub fn open_bytes_with(s: &mut Session, name: &str, bytes: &[u8], path: Option<S
|
||||
/// File → New from Template: any readable file (at `path`, if it is one: its links are looked for
|
||||
/// from there) as a new untitled document.
|
||||
pub fn open_template(s: &mut Session, name: &str, bytes: &[u8], path: Option<&str>) -> Result<Value> {
|
||||
open_loaded(s, load(name, bytes)?, path.map(str::to_string), &LoadOptions::default(), true)
|
||||
let loaded = load(name, bytes)?;
|
||||
if loaded.preview_only {
|
||||
return Err(err(
|
||||
"only this Affinity file's embedded preview could be read: use File › Open to see it with its warning, or export SVG or PDF from Affinity first",
|
||||
));
|
||||
}
|
||||
open_loaded(s, loaded, path.map(str::to_string), &LoadOptions::default(), true)
|
||||
}
|
||||
|
||||
/// Make a loaded file the new active document; `opts` are the options it was read with.
|
||||
fn open_loaded(s: &mut Session, loaded: Loaded, path: Option<String>, opts: &LoadOptions, as_template: bool) -> Result<Value> {
|
||||
let Loaded { mut doc, format, warnings, restored, converted } = loaded;
|
||||
let Loaded { mut doc, format, warnings, restored, converted, .. } = loaded;
|
||||
let links = crate::cmd::links::resolve(&mut doc, path.as_deref(), s.prefs.update_links == "automatically");
|
||||
// A template (saved by Save as Template, or an .ait/.vctemplate file) opens as a new untitled
|
||||
// document.
|
||||
@@ -329,7 +347,7 @@ pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
|
||||
|
||||
/// An image as a document of its physical size at the resolution it declares (as Place sizes it;
|
||||
/// 72 ppi, 1 px = 1 pt, when it declares none), the image named after the file.
|
||||
fn raster_doc(name: &str, bytes: &[u8]) -> Result<Document> {
|
||||
pub(super) fn raster_doc(name: &str, bytes: &[u8]) -> Result<Document> {
|
||||
let RasterImage { key, blob, width, height, ppi } = raster_image(bytes)?;
|
||||
let (sx, sy) = crate::cmd::place::pt_per_px(ppi);
|
||||
let mut d = Document::new(width as f64 * sx, height as f64 * sy);
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
//! [`FORMATS`] is the single list of formats (append-only); open dialogs use [`open_filters`],
|
||||
//! agents query `document.formats`.
|
||||
|
||||
mod affinity;
|
||||
mod batch;
|
||||
pub mod dxf;
|
||||
pub mod dxfimport;
|
||||
@@ -555,6 +556,9 @@ pub const FORMATS: &[Format] = &[
|
||||
Format { id: "wmf", label: "WMF", extensions: &["wmf"], mime: "image/wmf", read: true, write: true, raster: false, options: metafile::OPTIONS },
|
||||
Format { id: "tga", label: "Targa", extensions: &["tga"], mime: "image/x-tga", read: false, write: true, raster: true, options: TGA_OPTIONS },
|
||||
Format { id: "psd", label: "PSD", extensions: &["psd"], mime: "image/x-psd", read: false, write: true, raster: true, options: PSD_OPTIONS },
|
||||
// Affinity Photo's `.afphoto` opens by its content but isn't listed: Finder shouldn't offer a
|
||||
// vector app for a raster editor's documents.
|
||||
reader("affinity", "Affinity", &["af", "afdesign", "afpub"], "application/vnd.affinity", false),
|
||||
];
|
||||
|
||||
/// Every extension `document.open` reads (the "All readable files" filter of open dialogs).
|
||||
@@ -579,6 +583,9 @@ pub const OPEN_EXTS: &[&str] = &[
|
||||
"emf",
|
||||
"wmf",
|
||||
"eps",
|
||||
"af",
|
||||
"afdesign",
|
||||
"afpub",
|
||||
];
|
||||
|
||||
/// The extension that picks each writable format when exporting (the format's first; PNG-8 shares
|
||||
@@ -618,6 +625,9 @@ pub const PLACE_EXTS: &[&str] = &[
|
||||
"emf",
|
||||
"wmf",
|
||||
"eps",
|
||||
"af",
|
||||
"afdesign",
|
||||
"afpub",
|
||||
"txt",
|
||||
];
|
||||
|
||||
@@ -638,9 +648,11 @@ pub fn open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static
|
||||
.chain(std::iter::once(("Plug-ins", super::plugin::EXTS)))
|
||||
}
|
||||
|
||||
/// File → Place dialog filters: "All placeable files", then one per readable format, then text.
|
||||
/// File → Place dialog filters: "All placeable files", then one per placeable format, then text.
|
||||
pub fn place_filters() -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
|
||||
std::iter::once(("All placeable files", PLACE_EXTS)).chain(format_filters()).chain(std::iter::once(("Text", TEXT_EXTS)))
|
||||
std::iter::once(("All placeable files", PLACE_EXTS))
|
||||
.chain(format_filters().filter(|(_, exts)| exts.iter().all(|ext| PLACE_EXTS.contains(ext))))
|
||||
.chain(std::iter::once(("Text", TEXT_EXTS)))
|
||||
}
|
||||
|
||||
/// A format by id or extension (any case, leading dot allowed; `jpeg` finds `jpg`).
|
||||
@@ -860,6 +872,8 @@ pub(crate) fn write_or_return(path: Option<&str>, bytes: &[u8], extra: Value) ->
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_affinity;
|
||||
#[cfg(test)]
|
||||
mod tests_pdf;
|
||||
#[cfg(test)]
|
||||
mod tests_pdfcolor;
|
||||
|
||||
@@ -153,6 +153,9 @@ pub(super) fn encode(doc: &Document, p: &Value, compressed: bool) -> Result<Enco
|
||||
let (opts, boards, chosen) = plan(doc, p)?;
|
||||
// Preserve editing embeds the native document (once, shared by every file).
|
||||
let native = opts.preserve_editing.then(|| vectorcraft_format::save(doc, false));
|
||||
// SVG has no filters for the Photoshop-style effects: their objects go in as images.
|
||||
let flat = crate::cmd::rasterfx::flatten_pixel_effects(doc);
|
||||
let doc = flat.as_ref().unwrap_or(doc);
|
||||
let mut enc = Encoded::default();
|
||||
for artboard in boards {
|
||||
// A chosen artboard's file holds the art over it (#550).
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
//! Affinity documents: native import (layers, artboards, curves, paints) through Open, Place and
|
||||
//! templates, and the embedded preview only as an explicitly warned fallback.
|
||||
|
||||
use super::*;
|
||||
use proptest::prelude::*;
|
||||
use serde_json::json;
|
||||
use std::io::Cursor;
|
||||
|
||||
fn png() -> Vec<u8> {
|
||||
let im = image::RgbaImage::from_fn(2, 1, |x, _| if x == 0 { image::Rgba([220, 40, 60, 255]) } else { image::Rgba([0, 0, 0, 0]) });
|
||||
let mut bytes = Vec::new();
|
||||
im.write_to(&mut Cursor::new(&mut bytes), image::ImageFormat::Png).unwrap();
|
||||
bytes
|
||||
}
|
||||
|
||||
/// Synthetic envelope without a native object graph; not a native Affinity writer.
|
||||
fn file(png: &[u8]) -> Vec<u8> {
|
||||
let mut b = vec![0; 72];
|
||||
b[..4].copy_from_slice(vectorcraft_affinity::MAGIC);
|
||||
b[4..6].copy_from_slice(&12u16.to_le_bytes());
|
||||
b[8..12].copy_from_slice(b"nsrP");
|
||||
b[12..16].copy_from_slice(b"#Inf");
|
||||
b[24..32].copy_from_slice(&72u64.to_le_bytes());
|
||||
b[64..68].copy_from_slice(b"Prot");
|
||||
b.extend(b"\xff\xff\xff\xffThmb");
|
||||
b.extend(1u32.to_le_bytes());
|
||||
b.extend((png.len() as u32 + 13).to_le_bytes());
|
||||
b.extend(29u32.to_le_bytes());
|
||||
b.extend(0u32.to_le_bytes());
|
||||
b.extend((png.len() as u32).to_le_bytes());
|
||||
b.push(1);
|
||||
b.extend(png);
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_opens_with_a_warning_and_never_saves_to_the_source() {
|
||||
let mut s = Session::new();
|
||||
let bytes = file(&png());
|
||||
for name in ["drawing.af", "renamed.png", "unknown"] {
|
||||
let result = open_bytes(&mut s, name, &bytes, Some(format!("/source/{name}"))).unwrap();
|
||||
assert_eq!(result["format"], "affinity");
|
||||
let w = result["warnings"][0].as_str().unwrap();
|
||||
assert!(w.contains("2×1") && w.contains("native Affinity document could not be read") && w.contains("no layers"), "{w}");
|
||||
let st = s.doc().unwrap();
|
||||
assert!(st.path.is_none());
|
||||
assert_eq!(st.format, "vectorcraft");
|
||||
assert_eq!((st.doc.artboards[0].rect.width(), st.doc.artboards[0].rect.height()), (2.0, 1.0));
|
||||
assert_eq!(st.doc.images.values().next().unwrap().bytes.as_slice(), png());
|
||||
let saved = vectorcraft_format::save_file(&st.doc);
|
||||
let reopened = vectorcraft_format::load_file(&saved).unwrap();
|
||||
assert_eq!(reopened.doc.images, st.doc.images);
|
||||
let plan = save_plan(&s, SaveMode::Save, &json!({})).unwrap();
|
||||
assert!(plan.path.is_none(), "Save must ask for a new destination");
|
||||
assert_eq!(plan.format.id, "vectorcraft");
|
||||
assert!(plan.name.ends_with(".vectorcraft"));
|
||||
}
|
||||
let f = format("affinity").unwrap();
|
||||
assert!(f.read && !f.write);
|
||||
for ext in ["af", "afdesign", "afpub"] {
|
||||
assert!(OPEN_EXTS.contains(&ext) && PLACE_EXTS.contains(&ext), "{ext}");
|
||||
}
|
||||
assert!(!SAVE_FORMATS.contains(&"affinity"));
|
||||
assert!(s.execute("document.serialize", &json!({"format":"af"})).is_err());
|
||||
assert!(s.execute("document.export", &json!({"format":"affinity"})).is_err());
|
||||
assert!(save_format(None, Some("drawing.af")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_reaches_the_document_open_command() {
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("document.open", &json!({"name":"test.af", "dataBase64":vectorcraft_format::base64_encode(&file(&png()))})).unwrap();
|
||||
assert_eq!(r["format"], "affinity");
|
||||
assert_eq!(r["warnings"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placing_a_preview_is_rejected_even_when_renamed_or_queued() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width":100,"height":100})).unwrap();
|
||||
let original = s.doc().unwrap().doc.clone();
|
||||
for name in ["test.af", "renamed.png", "renamed.txt"] {
|
||||
let p = json!({"name":name, "dataBase64":vectorcraft_format::base64_encode(&file(&png())), "link":true});
|
||||
for cmd in ["file.place", "file.place.info"] {
|
||||
let e = s.execute(cmd, &p).unwrap_err().to_string();
|
||||
assert!(e.contains("File › Open") && e.contains("embedded preview"), "{e}");
|
||||
}
|
||||
let e = s.execute("file.place.queue", &json!({"files":[p]})).unwrap_err().to_string();
|
||||
assert!(e.contains("File › Open") && e.contains("embedded preview"), "{e}");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().doc, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn templates_and_libraries_cannot_silently_extract_preview_content() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width":100,"height":100})).unwrap();
|
||||
let original = s.doc().unwrap().doc.clone();
|
||||
for name in ["test.af", "renamed.svg"] {
|
||||
let p = json!({"name":name, "dataBase64":vectorcraft_format::base64_encode(&file(&png()))});
|
||||
for cmd in ["file.newFromTemplate", "swatch.library.load", "graphicStyle.loadLibrary"] {
|
||||
let e = s.execute(cmd, &p).unwrap_err().to_string();
|
||||
assert!(e.contains("File › Open"), "{cmd}: {e}");
|
||||
}
|
||||
}
|
||||
assert_eq!(s.documents().len(), 1);
|
||||
assert_eq!(s.doc().unwrap().doc, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_crc_and_every_truncation_fail() {
|
||||
let b = file(&png());
|
||||
for end in 0..b.len() {
|
||||
assert!(load("bad.af", &b[..end]).is_err(), "{end}");
|
||||
}
|
||||
let mut crc = b.clone();
|
||||
crc[130] ^= 1;
|
||||
assert!(load("bad.af", &crc).is_err());
|
||||
// Affinity 1 and 2 (container versions 8 to 11) store the same preview record.
|
||||
let mut legacy = b.clone();
|
||||
legacy[4..6].copy_from_slice(&10u16.to_le_bytes());
|
||||
assert!(load("old.afdesign", &legacy).is_ok());
|
||||
let mut unknown = b.clone();
|
||||
unknown[4..6].copy_from_slice(&13u16.to_le_bytes());
|
||||
assert!(load("new.af", &unknown).err().unwrap().to_string().contains("unknown container version"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_and_post_image_chunk_crcs_cannot_escape_pixel_decoding() {
|
||||
let mut end_crc = png();
|
||||
*end_crc.last_mut().unwrap() ^= 1;
|
||||
assert!(load("bad-end.af", &file(&end_crc)).is_err());
|
||||
|
||||
let body = b"Note\0Synthetic";
|
||||
let mut text = (body.len() as u32).to_be_bytes().to_vec();
|
||||
text.extend(b"tEXt");
|
||||
text.extend(body);
|
||||
let mut h = crc32fast::Hasher::new();
|
||||
h.update(b"tEXt");
|
||||
h.update(body);
|
||||
text.extend(h.finalize().to_be_bytes());
|
||||
let mut post_image = png();
|
||||
let at = post_image.len() - 12;
|
||||
post_image.splice(at..at, text);
|
||||
assert!(load("valid-text.af", &file(&post_image)).is_ok());
|
||||
post_image[at + 8 + body.len()] ^= 1;
|
||||
assert!(load("bad-text.af", &file(&post_image)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_previewless_file_fails_without_replacing_the_active_document() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width":100,"height":100})).unwrap();
|
||||
let original = s.doc().unwrap().doc.clone();
|
||||
let mut bytes = file(&png());
|
||||
bytes[24..32].copy_from_slice(&0u64.to_le_bytes());
|
||||
let e = s.execute("document.open", &json!({"name":"no-preview.af", "dataBase64":vectorcraft_format::base64_encode(&bytes)})).unwrap_err();
|
||||
assert!(e.to_string().contains("no embedded preview"));
|
||||
assert_eq!(s.documents().len(), 1);
|
||||
assert_eq!(s.doc().unwrap().doc, original);
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(128))]
|
||||
#[test]
|
||||
fn bounded_mutations_do_not_panic(edits in prop::collection::vec((0usize..300, any::<u8>()), 0..16)) {
|
||||
let mut b = file(&png());
|
||||
for (i, value) in edits { if let Some(byte) = b.get_mut(i) { *byte = value; } }
|
||||
let _ = load("mutated.af", &b);
|
||||
}
|
||||
}
|
||||
|
||||
mod native {
|
||||
use super::*;
|
||||
use vectorcraft_affinity::synth::{self, F, Method, tag};
|
||||
use vectorcraft_doc::{NodeKind, appearance::AppearanceItem};
|
||||
|
||||
fn rgba(r: f32, g: f32, b: f32, a: f32) -> F {
|
||||
F::Struct([r, g, b, a].iter().flat_map(|v| v.to_le_bytes()).collect())
|
||||
}
|
||||
|
||||
fn solid(id: u32, c: F) -> F {
|
||||
F::Def(
|
||||
id,
|
||||
vec![tag(b"FDsc")],
|
||||
vec![(
|
||||
tag(b"FDeF"),
|
||||
F::Def(id + 1, vec![tag(b"FilS")], vec![(tag(b"Colr"), F::Def(id + 2, vec![tag(b"RGBA")], vec![(tag(b"_col"), c)]))]),
|
||||
)],
|
||||
)
|
||||
}
|
||||
|
||||
fn node(x: f64, y: f64, kind: u8, role: u8) -> Vec<u8> {
|
||||
let mut r = x.to_le_bytes().to_vec();
|
||||
r.extend(y.to_le_bytes());
|
||||
r.extend([kind, role]);
|
||||
r
|
||||
}
|
||||
|
||||
/// A version-12 document at 144 ppi: an artboard "Board" (moved 20, 10) holding a closed
|
||||
/// triangle with a red fill and a 4 px blue stroke, and a hidden ellipse on a layer.
|
||||
pub(super) fn document(method: Method) -> Vec<u8> {
|
||||
let triangle = F::Def(
|
||||
20,
|
||||
vec![tag(b"PCrv"), tag(b"VNod"), tag(b"Node")],
|
||||
vec![
|
||||
(tag(b"Desc"), F::Str("Triangle".into())),
|
||||
(
|
||||
tag(b"Crvs"),
|
||||
F::Obj(
|
||||
tag(b"PCvD"),
|
||||
vec![(
|
||||
tag(b"Data"),
|
||||
F::Pos(vec![
|
||||
F::U8(0),
|
||||
F::U32(1),
|
||||
F::Bool(true),
|
||||
F::Records(18, vec![node(0.0, 0.0, 1, 0), node(100.0, 0.0, 1, 0), node(50.0, 80.0, 1, 0), node(0.0, 0.0, 1, 0)]),
|
||||
]),
|
||||
)],
|
||||
),
|
||||
),
|
||||
(tag(b"BFFl"), F::Shared(vec![solid(21, rgba(1.0, 0.0, 0.0, 1.0))])),
|
||||
(tag(b"LIFl"), F::Shared(vec![solid(24, rgba(0.0, 0.0, 1.0, 1.0))])),
|
||||
(
|
||||
tag(b"LILn"),
|
||||
F::Shared(vec![F::Def(
|
||||
27,
|
||||
vec![tag(b"LDsc")],
|
||||
vec![(tag(b"LDeL"), F::Def(28, vec![tag(b"LSty")], vec![(tag(b"Wght"), F::F64(4.0))])), (tag(b"LDSc"), F::Bool(true))],
|
||||
)]),
|
||||
),
|
||||
],
|
||||
);
|
||||
let board = F::Def(
|
||||
10,
|
||||
vec![tag(b"ShpN"), tag(b"VNod"), tag(b"Node")],
|
||||
vec![
|
||||
(tag(b"Desc"), F::Str("Board".into())),
|
||||
(tag(b"ABEn"), F::Bool(true)),
|
||||
(tag(b"Shpe"), F::Def(11, vec![tag(b"ShNR")], vec![])),
|
||||
(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 200.0, 100.0])),
|
||||
(tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, 20.0, 0.0, 1.0, 10.0])),
|
||||
(tag(b"BFFl"), F::Shared(vec![solid(12, rgba(1.0, 1.0, 1.0, 1.0))])),
|
||||
(tag(b"Chld"), F::Shared(vec![triangle])),
|
||||
],
|
||||
);
|
||||
let ellipse = F::Def(
|
||||
40,
|
||||
vec![tag(b"ShpN")],
|
||||
vec![
|
||||
(tag(b"Visi"), F::Bool(false)),
|
||||
(tag(b"Shpe"), F::Def(41, vec![tag(b"ShpE")], vec![])),
|
||||
(tag(b"ShpB"), F::F64s(vec![300.0, 0.0, 400.0, 50.0])),
|
||||
(tag(b"BFFl"), F::Shared(vec![F::Def(42, vec![tag(b"FDsc")], vec![(tag(b"FDeF"), F::Def(43, vec![tag(b"FilN")], vec![]))])])),
|
||||
],
|
||||
);
|
||||
let layer = F::Def(30, vec![tag(b"Scop")], vec![(tag(b"Desc"), F::Str("Shapes".into())), (tag(b"Chld"), F::Shared(vec![ellipse]))]);
|
||||
let spread =
|
||||
F::Def(2, vec![tag(b"Sprd")], vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 420.0, 120.0])), (tag(b"Chld"), F::Shared(vec![board, layer]))]);
|
||||
let doc = synth::stream(&[
|
||||
(tag(b"UVCn"), F::Obj(tag(b"UVCn"), vec![(tag(b"UPPI"), F::F64(144.0))])),
|
||||
(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))])),
|
||||
]);
|
||||
synth::container(&[("doc.dat", &doc, method)], Some(&png()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_artboards_curves_and_paints_open_as_editable_art() {
|
||||
for method in [Method::Stored, Method::Zlib, Method::Zstd] {
|
||||
let mut s = Session::new();
|
||||
let r = open_bytes(&mut s, "native.af", &document(method), Some("/source/native.af".into())).unwrap();
|
||||
assert_eq!(r["format"], "affinity");
|
||||
// Affinity Photo's extension isn't listed, but its documents still open by their content.
|
||||
assert_eq!(open_bytes(&mut Session::new(), "native.afphoto", &document(method), None).unwrap()["format"], "affinity");
|
||||
assert!(r["warnings"].as_array().unwrap().iter().all(|w| !w.as_str().unwrap().contains("preview")), "{r}");
|
||||
let st = s.doc().unwrap();
|
||||
assert!(st.path.is_none(), "Save must not write back over the Affinity file");
|
||||
let d = &st.doc;
|
||||
// 144 ppi: two document pixels per point.
|
||||
assert_eq!(d.artboards.len(), 1);
|
||||
assert_eq!(d.artboards[0].name, "Board");
|
||||
assert_eq!(d.artboards[0].rect, kurbo::Rect::new(10.0, 5.0, 110.0, 55.0));
|
||||
let names: Vec<_> = d.layers.iter().map(|l| l.name.clone().unwrap()).collect();
|
||||
assert_eq!(names, ["Board", "Shapes"]);
|
||||
let NodeKind::Layer { children, .. } = &d.layers[0].kind else { panic!() };
|
||||
let NodeKind::Group { children: clipped, clip: true } = &children[0].kind else { panic!("{:?}", children[0].kind) };
|
||||
let NodeKind::Path { clipping: true, .. } = &clipped[0].kind else { panic!() };
|
||||
let tri = &clipped[1];
|
||||
assert_eq!(tri.name.as_deref(), Some("Triangle"));
|
||||
let NodeKind::Path { path, .. } = &tri.kind else { panic!() };
|
||||
let pts: Vec<_> = path.subpaths[0].anchors.iter().map(|a| (a.p.x, a.p.y)).collect();
|
||||
assert_eq!(pts, [(10.0, 5.0), (60.0, 5.0), (35.0, 45.0)]);
|
||||
assert!(path.subpaths[0].closed);
|
||||
let [AppearanceItem::Fill(f), AppearanceItem::Stroke(k)] = tri.appearance.items.as_slice() else { panic!("{:?}", tri.appearance) };
|
||||
assert_eq!(f.paint, vectorcraft_color::Paint::solid(vectorcraft_color::Color::Rgb { r: 1.0, g: 0.0, b: 0.0 }));
|
||||
assert_eq!(k.width, 2.0);
|
||||
let NodeKind::Layer { children, .. } = &d.layers[1].kind else { panic!() };
|
||||
assert!(!children[0].visible, "hidden layers stay hidden");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_documents_save_reopen_export_place_and_template() {
|
||||
let bytes = document(Method::Zstd);
|
||||
let mut s = Session::new();
|
||||
open_bytes(&mut s, "native.af", &bytes, None).unwrap();
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let reopened = vectorcraft_format::load_file(&vectorcraft_format::save_file(&doc)).unwrap();
|
||||
assert_eq!(reopened.doc.layers, doc.layers);
|
||||
for format in ["svg", "pdf", "png"] {
|
||||
assert!(s.execute("document.export", &json!({"format": format})).is_ok(), "{format}");
|
||||
}
|
||||
let p = json!({"name":"native.afdesign", "dataBase64":vectorcraft_format::base64_encode(&bytes)});
|
||||
s.execute("file.new", &json!({"width":100,"height":100})).unwrap();
|
||||
s.execute("file.place", &p).unwrap();
|
||||
assert!(s.doc().unwrap().doc.layers.iter().any(|l| !l.children().unwrap().is_empty()));
|
||||
s.execute("file.newFromTemplate", &p).unwrap();
|
||||
assert!(s.doc().unwrap().path.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damaged_native_document_falls_back_to_its_preview_with_the_reason() {
|
||||
let mut bytes = document(Method::Zlib);
|
||||
// Break the doc.dat CRC: the preview still opens, saying why.
|
||||
bytes[80] ^= 0xFF;
|
||||
let r = load("broken.af", &bytes).unwrap();
|
||||
assert!(r.preview_only);
|
||||
assert!(r.warnings[0].contains("could not be read (damaged Affinity file"), "{}", r.warnings[0]);
|
||||
}
|
||||
}
|
||||
@@ -1265,11 +1265,11 @@ fn reach(n: &Node) -> Option<Rect> {
|
||||
if !n.visible {
|
||||
return None;
|
||||
}
|
||||
let fx = n.appearance.items.iter().map(|i| effects::outset(i.effects())).fold(effects::outset(&n.appearance.effects), f64::max);
|
||||
let b = match n.children() {
|
||||
Some(ch) if !n.clips() && !matches!(n.kind, NodeKind::Compound { .. }) => ch.iter().filter_map(|c| reach(c)).reduce(|a, b| a.union(b))?,
|
||||
_ => n.visual_bounds()?,
|
||||
};
|
||||
let fx = n.appearance.items.iter().map(|i| effects::outset(i.effects(), b)).fold(effects::outset(&n.appearance.effects, b), f64::max);
|
||||
Some(b.inflate(fx + 1.0, fx + 1.0))
|
||||
}
|
||||
|
||||
|
||||
@@ -136,7 +136,8 @@ pub(crate) fn pt_per_px(ppi: Option<(f64, f64)>) -> (f64, f64) {
|
||||
fn load(p: &Value, cmd: &str, board: Option<Rect>) -> Result<Loaded> {
|
||||
let src = fileio::source(p, cmd)?;
|
||||
let name = fileio::file_name(src.name);
|
||||
if fileio::TEXT_EXTS.contains(&fileio::extension(src.name).as_str()) {
|
||||
// An Affinity file is recognised by its signature, whatever its name says.
|
||||
if fileio::TEXT_EXTS.contains(&fileio::extension(src.name).as_str()) && !vectorcraft_affinity::is_affinity(&src.bytes) {
|
||||
let text = text::import(&src.bytes, text::TextOptions::parse(p, cmd)?, cmd)?;
|
||||
if text.trim().is_empty() {
|
||||
return Err(bad(cmd, format!("`{name}` has no text to place")));
|
||||
@@ -196,6 +197,16 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>) -> Result<Loaded> {
|
||||
// Of the open options, only a DXF drawing's apply (the colour mode stays the file's).
|
||||
let o = fileio::LoadOptions { dxf: opts.dxf, ..Default::default() };
|
||||
let mut l = fileio::load_with(src.name, &src.bytes, &o)?;
|
||||
// An Affinity file whose native document couldn't be read is only its preview: it
|
||||
// must never silently stand in for the art.
|
||||
if l.preview_only {
|
||||
return Err(bad(
|
||||
cmd,
|
||||
format!(
|
||||
"{name}: only this Affinity file's embedded preview could be read; use File › Open to see it with its warning, or export SVG or PDF from Affinity before placing"
|
||||
),
|
||||
));
|
||||
}
|
||||
// Linked images show their files, found from its folder.
|
||||
super::links::resolve(&mut l.doc, src.path, false);
|
||||
(l.doc, l.warnings)
|
||||
|
||||
@@ -275,6 +275,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[
|
||||
])
|
||||
),
|
||||
p!("autoCollapseIconPanels", "User Interface", "", "Auto-Collapse Iconic Panels", bool),
|
||||
p!("toolGroupLabels", "User Interface", "", "Show Tool Group Labels", bool),
|
||||
p!("openDocumentsAsTabs", "User Interface", "", "Open Documents As Tabs", bool),
|
||||
p!("largeTabs", "User Interface", "", "Large Tabs", bool),
|
||||
p!("uiScaling", "User Interface", "UI Scaling", "Scale", num(0.75, 2.0, "×")),
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
//! effect: the image (with the object's own area knocked out) goes under the untouched vector
|
||||
//! object. Effects that change the object itself (inner glow, feather, Gaussian blur) replace it
|
||||
//! with the image. Expand Appearance makes its images with the same helpers.
|
||||
//!
|
||||
//! SVG has filters for shadows, glows, blurs and feathers but none for the Photoshop-style
|
||||
//! effects (Radial Blur, Unsharp Mask…): SVG export turns only the objects carrying one of those
|
||||
//! into images the same way ([`flatten_pixel_effects`]).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -167,7 +171,7 @@ pub(crate) fn effect_image(out: &mut Document, whole: &Node, knockout: Option<&N
|
||||
// effects reach beyond that.
|
||||
let b = vectorcraft_render::painted_bounds(whole)?;
|
||||
let look = out.raster_effects.clone();
|
||||
let reach = whole.appearance.items.iter().map(|i| effects::outset(i.effects())).fold(0.0, f64::max) + 2.0 + look.add_around;
|
||||
let reach = whole.appearance.items.iter().map(|i| effects::outset(i.effects(), b)).fold(0.0, f64::max) + 2.0 + look.add_around;
|
||||
let b = b.inflate(reach, reach);
|
||||
// Whole pixels, and no larger than 64 Mpx.
|
||||
let scale = scale.min((64.0e6 / (b.width() * b.height()).max(1.0)).sqrt());
|
||||
@@ -337,20 +341,39 @@ fn placed_symbol(d: &Document, n: &Node) -> Option<Node> {
|
||||
Some(Node { kind: NodeKind::Group { children: vec![Arc::new(art)], clip: false }, ..n.clone() })
|
||||
}
|
||||
|
||||
/// Does `n` hold raster effects PDF export draws: its own, its fills' and strokes', its opacity
|
||||
/// mask's or (groups and layers) its members'? Hidden objects and template layers aren't drawn.
|
||||
fn needs(n: &Node) -> bool {
|
||||
/// Which objects' raster effects become images.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Which {
|
||||
/// Every raster effect (PDF).
|
||||
All,
|
||||
/// Objects with a Photoshop-style effect, which SVG can't draw (with all their raster effects).
|
||||
Pixel,
|
||||
}
|
||||
|
||||
/// Has `n` (its own, its fills' or strokes') raster effects `which` turns into an image?
|
||||
fn imaged(n: &Node, which: Which) -> bool {
|
||||
let fx = raster_fx(n);
|
||||
match which {
|
||||
Which::All => !fx.is_empty(),
|
||||
Which::Pixel => fx.iter().any(|f| matches!(f, RasterFx::Pixel(_))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Does `n` hold raster effects that `which` turns into images: its own, its fills' and strokes',
|
||||
/// its opacity mask's or (groups and layers) its members'? Hidden objects and template layers
|
||||
/// aren't drawn.
|
||||
fn needs(n: &Node, which: Which) -> bool {
|
||||
n.visible
|
||||
&& !matches!(n.kind, NodeKind::Layer { template: true, .. })
|
||||
&& (!raster_fx(n).is_empty()
|
||||
|| n.mask.as_ref().is_some_and(|m| needs(&m.art))
|
||||
|| (is_container(n) && n.children().is_some_and(|ch| ch.iter().any(|c| needs(c)))))
|
||||
&& (imaged(n, which)
|
||||
|| n.mask.as_ref().is_some_and(|m| needs(&m.art, which))
|
||||
|| (is_container(n) && n.children().is_some_and(|ch| ch.iter().any(|c| needs(c, which)))))
|
||||
}
|
||||
|
||||
/// The art of `n`'s opacity mask with its raster effects as images.
|
||||
fn walk_mask(out: &mut Document, n: &mut Node) {
|
||||
fn walk_mask(out: &mut Document, n: &mut Node, which: Which) {
|
||||
if let Some(m) = n.mask.as_mut()
|
||||
&& let Some(art) = walk(out, &m.art)
|
||||
&& let Some(art) = walk(out, &m.art, which)
|
||||
{
|
||||
m.art = Arc::new(art);
|
||||
}
|
||||
@@ -359,15 +382,15 @@ fn walk_mask(out: &mut Document, n: &mut Node) {
|
||||
/// `n` with its raster effects (and its members' and its opacity mask's) as images, `None` when it
|
||||
/// has none. An object whose effects all paint below it keeps its vector art over an image of
|
||||
/// them; one whose effects change it (blur, feather, inner glow) becomes the image.
|
||||
fn walk(out: &mut Document, n: &Node) -> Option<Node> {
|
||||
if !needs(n) {
|
||||
fn walk(out: &mut Document, n: &Node, which: Which) -> Option<Node> {
|
||||
if !needs(n, which) {
|
||||
return None;
|
||||
}
|
||||
if raster_fx(n).is_empty() {
|
||||
if !imaged(n, which) {
|
||||
let mut m = n.clone();
|
||||
walk_mask(out, &mut m);
|
||||
walk_mask(out, &mut m, which);
|
||||
if is_container(&m)
|
||||
&& let Some(ch) = walk_all(out, n.children()?)
|
||||
&& let Some(ch) = walk_all(out, n.children()?, which)
|
||||
&& let Some(slot) = m.children_mut()
|
||||
{
|
||||
*slot = ch;
|
||||
@@ -375,30 +398,30 @@ fn walk(out: &mut Document, n: &Node) -> Option<Node> {
|
||||
return Some(m);
|
||||
}
|
||||
if let Some(g) = placed_symbol(out, n) {
|
||||
return Some(walk(out, &g).unwrap_or(g));
|
||||
return Some(walk(out, &g, which).unwrap_or(g));
|
||||
}
|
||||
let below = below_only(n);
|
||||
if !below && let Some(g) = split_items(out, n) {
|
||||
return Some(walk(out, &g).unwrap_or(g));
|
||||
return Some(walk(out, &g, which).unwrap_or(g));
|
||||
}
|
||||
// An image that can't be made leaves the object as it is (the writer reports its effects).
|
||||
let image = raster_image(out, n, below, false)?;
|
||||
let mut v = n.clone();
|
||||
strip_raster(&mut v, false);
|
||||
let mut m = if below {
|
||||
let mut m = walk(out, &v).unwrap_or(v);
|
||||
let mut m = walk(out, &v, which).unwrap_or(v);
|
||||
put_below(out, &mut m, image);
|
||||
m
|
||||
} else {
|
||||
replace_with_image(v, image)
|
||||
};
|
||||
walk_mask(out, &mut m);
|
||||
walk_mask(out, &mut m, which);
|
||||
Some(m)
|
||||
}
|
||||
|
||||
/// `nodes` with their raster effects as images, `None` when none has any.
|
||||
fn walk_all(out: &mut Document, nodes: &[Arc<Node>]) -> Option<Vec<Arc<Node>>> {
|
||||
nodes.iter().any(|n| needs(n)).then(|| nodes.iter().map(|n| walk(out, n).map(Arc::new).unwrap_or_else(|| n.clone())).collect())
|
||||
fn walk_all(out: &mut Document, nodes: &[Arc<Node>], which: Which) -> Option<Vec<Arc<Node>>> {
|
||||
nodes.iter().any(|n| needs(n, which)).then(|| nodes.iter().map(|n| walk(out, n, which).map(Arc::new).unwrap_or_else(|| n.clone())).collect())
|
||||
}
|
||||
|
||||
/// A copy of `doc` with raster effects turned into images at the document's raster effects
|
||||
@@ -406,26 +429,37 @@ fn walk_all(out: &mut Document, nodes: &[Arc<Node>]) -> Option<Vec<Arc<Node>>> {
|
||||
/// type, images, symbol instances, live objects) and on single fills and strokes, in the layers,
|
||||
/// opacity masks, symbol definitions and pattern tiles.
|
||||
pub fn flatten_raster_effects(doc: &Document) -> Option<Document> {
|
||||
let any = |nodes: &[Arc<Node>]| nodes.iter().any(|n| needs(n));
|
||||
if !any(&doc.layers) && !doc.symbols.iter().any(|s| needs(&s.art)) && !doc.patterns.iter().any(|p| any(&p.art)) {
|
||||
flatten(doc, Which::All)
|
||||
}
|
||||
|
||||
/// A copy of `doc` in which the objects with Photoshop-style raster effects (Radial Blur, Unsharp
|
||||
/// Mask…) are images as [`flatten_raster_effects`] makes them (SVG export: its filters draw the
|
||||
/// other raster effects live), or `None` when there are none.
|
||||
pub fn flatten_pixel_effects(doc: &Document) -> Option<Document> {
|
||||
flatten(doc, Which::Pixel)
|
||||
}
|
||||
|
||||
fn flatten(doc: &Document, which: Which) -> Option<Document> {
|
||||
let any = |nodes: &[Arc<Node>]| nodes.iter().any(|n| needs(n, which));
|
||||
if !any(&doc.layers) && !doc.symbols.iter().any(|s| needs(&s.art, which)) && !doc.patterns.iter().any(|p| any(&p.art)) {
|
||||
return None;
|
||||
}
|
||||
// Geometry effects first, so the vector objects kept above shadows are final.
|
||||
let baked = effects::bake_document(doc);
|
||||
let src = baked.as_ref().unwrap_or(doc);
|
||||
let mut out = src.clone();
|
||||
if let Some(layers) = walk_all(&mut out, &src.layers) {
|
||||
if let Some(layers) = walk_all(&mut out, &src.layers, which) {
|
||||
out.layers = layers;
|
||||
}
|
||||
for i in 0..out.symbols.len() {
|
||||
if let Some(art) = out.symbols.get(i).map(|s| s.art.clone()).and_then(|a| walk(&mut out, &a))
|
||||
if let Some(art) = out.symbols.get(i).map(|s| s.art.clone()).and_then(|a| walk(&mut out, &a, which))
|
||||
&& let Some(s) = out.symbols.get_mut(i)
|
||||
{
|
||||
s.art = Arc::new(art);
|
||||
}
|
||||
}
|
||||
for i in 0..out.patterns.len() {
|
||||
if let Some(art) = out.patterns.get(i).map(|p| p.art.clone()).and_then(|a| walk_all(&mut out, &a))
|
||||
if let Some(art) = out.patterns.get(i).map(|p| p.art.clone()).and_then(|a| walk_all(&mut out, &a, which))
|
||||
&& let Some(p) = out.patterns.get_mut(i)
|
||||
{
|
||||
p.art = art;
|
||||
|
||||
@@ -222,8 +222,11 @@ fn load(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
match std::str::from_utf8(bytes).ok().filter(|t| style_libs::sniff(t)) {
|
||||
Some(t) => style_libs::read(t, stem(file)).map_err(|e| bad(C, e)),
|
||||
None => {
|
||||
let doc = super::fileio::load(file, bytes).map_err(|e| bad(C, e.to_string()))?.doc;
|
||||
StyleLibrary::from_document(&doc, &[], stem(file).to_string()).map_err(|e| bad(C, e))
|
||||
let loaded = super::fileio::load(file, bytes).map_err(|e| bad(C, e.to_string()))?;
|
||||
if loaded.preview_only {
|
||||
return Err(bad(C, "only this Affinity file's embedded preview could be read; use File › Open to see it with its warning"));
|
||||
}
|
||||
StyleLibrary::from_document(&loaded.doc, &[], stem(file).to_string()).map_err(|e| bad(C, e))
|
||||
}
|
||||
}
|
||||
})?;
|
||||
|
||||
@@ -362,8 +362,11 @@ fn load(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if palette_io::sniff_bytes(bytes) {
|
||||
palette_io::read_bytes(bytes, stem(file)).map_err(|e| bad(C, e))
|
||||
} else {
|
||||
let doc = super::fileio::load(file, bytes).map_err(|e| bad(C, e.to_string()))?.doc;
|
||||
document_library(&doc, &[], stem(file).to_string(), C)
|
||||
let loaded = super::fileio::load(file, bytes).map_err(|e| bad(C, e.to_string()))?;
|
||||
if loaded.preview_only {
|
||||
return Err(bad(C, "only this Affinity file's embedded preview could be read; use File › Open to see it with its warning"));
|
||||
}
|
||||
document_library(&loaded.doc, &[], stem(file).to_string(), C)
|
||||
}
|
||||
})?;
|
||||
Ok(json!({"library": info.id, "name": info.name, "count": lib.len()}))
|
||||
|
||||
@@ -744,7 +744,8 @@ mod area_tests {
|
||||
use vectorcraft_doc::LeadingModel;
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "一\n二", "area": {"width": 200, "height": 100}})).unwrap()["id"]
|
||||
// Exercise ink bounds with bundled glyphs; Japanese outlines require optional craft-fonts.
|
||||
let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "A\nB", "area": {"width": 200, "height": 100}})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
|
||||
@@ -491,6 +491,9 @@ pub struct Prefs {
|
||||
pub ui_brightness: String,
|
||||
pub canvas_color: String,
|
||||
pub auto_collapse_icon_panels: bool,
|
||||
/// User Interface › Show Tool Group Labels: the toolbar's group names (Select, Shapes, Draw…);
|
||||
/// off, a faint dash separates the groups instead (#663).
|
||||
pub tool_group_labels: bool,
|
||||
pub open_documents_as_tabs: bool,
|
||||
pub large_tabs: bool,
|
||||
pub ui_scaling: f64,
|
||||
@@ -706,6 +709,7 @@ impl Default for Prefs {
|
||||
ui_brightness: s("mediumDark"),
|
||||
canvas_color: s("matchUi"),
|
||||
auto_collapse_icon_panels: false,
|
||||
tool_group_labels: true,
|
||||
open_documents_as_tabs: true,
|
||||
large_tabs: false,
|
||||
ui_scaling: 1.0,
|
||||
@@ -1548,6 +1552,8 @@ mod tests_proxyitems;
|
||||
#[cfg(test)]
|
||||
mod tests_puppetwarp;
|
||||
#[cfg(test)]
|
||||
mod tests_rasterfilters;
|
||||
#[cfg(test)]
|
||||
mod tests_rastersettings;
|
||||
#[cfg(test)]
|
||||
mod tests_recolor;
|
||||
|
||||
@@ -551,6 +551,26 @@ fn paste_on_all_artboards_keeps_the_offset_to_the_source_artboard() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Paste in Place, in Front and in Back onto the active artboard (#693): where the objects were on
|
||||
/// the artboard they were copied from, on the one named; without it, where they were.
|
||||
#[test]
|
||||
fn in_place_pastes_go_onto_the_artboard_named() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 3})).unwrap();
|
||||
let boards: Vec<Rect> = doc(&s).artboards.iter().map(|a| a.rect).collect();
|
||||
let r = rect(&mut s, boards[0].x0 + 10.0, boards[0].y0 + 15.0, 20.0, 20.0);
|
||||
copy(&mut s, &[r]);
|
||||
for cmd in ["edit.pasteInPlace", "edit.pasteInFront", "edit.pasteInBack"] {
|
||||
let ids = ids_of(&s.execute(cmd, &json!({"artboard": 2})).unwrap());
|
||||
assert_eq!(doc(&s).bounds_of(&ids, false).unwrap().origin(), Point::new(boards[2].x0 + 10.0, boards[2].y0 + 15.0), "{cmd}");
|
||||
let ids = ids_of(&s.execute(cmd, &json!({})).unwrap());
|
||||
assert_eq!(doc(&s).bounds_of(&ids, false).unwrap().origin(), Point::new(boards[0].x0 + 10.0, boards[0].y0 + 15.0), "{cmd} without one");
|
||||
// An artboard that isn't there: where they were.
|
||||
let ids = ids_of(&s.execute(cmd, &json!({"artboard": 9})).unwrap());
|
||||
assert_eq!(doc(&s).bounds_of(&ids, false).unwrap().origin(), Point::new(boards[0].x0 + 10.0, boards[0].y0 + 15.0), "{cmd} on no artboard");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_remembers_layers_restores_the_source_layers() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
//! The Photoshop-style raster effects (Effect › Blur › Radial Blur and Smart Blur, Sharpen ›
|
||||
//! Unsharp Mask): applied and edited as commands, listed in the catalogue, drawn on the canvas,
|
||||
//! and written to PDF and SVG as images of the effected object.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::NodeKind;
|
||||
use vectorcraft_geom::Rect;
|
||||
use vectorcraft_render::{Rendered, Renderer};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The new effects with parameters that change a striped square visibly.
|
||||
const EFFECTS: [(&str, &str); 3] = [
|
||||
("blur.radial", r#"{"amount": 30, "quality": "draft"}"#),
|
||||
("blur.smart", r#"{"radius": 8, "threshold": 100}"#),
|
||||
("sharpen.unsharpMask", r#"{"amount": 300, "radius": 3}"#),
|
||||
];
|
||||
|
||||
/// A group of a red square and a darker red stripe across it; returns the group's id.
|
||||
fn striped() -> (Session, u64) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
let id = s.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": "#e03020", "ids": [id]})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"none": true, "ids": [id]})).unwrap();
|
||||
let stripe = s.execute("shape.rectangle", &json!({"x": 90, "y": 50, "width": 20, "height": 100})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": "#b04848", "ids": [stripe]})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"none": true, "ids": [stripe]})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id, stripe]})).unwrap();
|
||||
let g = s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
(s, g)
|
||||
}
|
||||
|
||||
fn render(s: &Session) -> Rendered {
|
||||
Renderer::new().render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true)
|
||||
}
|
||||
|
||||
fn mean_diff(a: &Rendered, b: &Rendered) -> f64 {
|
||||
let sum: u64 = a.pixels.iter().zip(&b.pixels).map(|(x, y)| x.abs_diff(*y) as u64).sum();
|
||||
sum as f64 / a.pixels.len() as f64 / 255.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listed_applied_edited_and_undone() {
|
||||
let (mut s, g) = striped();
|
||||
let list = s.execute("effect.list", &json!({})).unwrap();
|
||||
for (id, _) in EFFECTS {
|
||||
let e = list["catalog"].as_array().unwrap().iter().find(|e| e["id"] == id).unwrap_or_else(|| panic!("{id} listed"));
|
||||
assert_eq!(e["raster"], json!(true), "{id}");
|
||||
assert!(e["params"].as_str().unwrap().contains("radius") || id == "blur.radial", "{id}");
|
||||
}
|
||||
let menus: Vec<Value> =
|
||||
EFFECTS.iter().map(|(id, _)| list["catalog"].as_array().unwrap().iter().find(|e| e["id"] == *id).unwrap()["menu"].clone()).collect();
|
||||
assert_eq!(menus, [json!(["Effect", "Blur"]), json!(["Effect", "Blur"]), json!(["Effect", "Sharpen"])]);
|
||||
// Junk parameters are stored as given and read clamped; the canvas still draws.
|
||||
for (id, _) in EFFECTS {
|
||||
s.execute("effect.apply", &json!({"effect": id, "ids": [g], "params": {"amount": 1e308, "radius": -1e308, "threshold": "x", "quality": 7}}))
|
||||
.unwrap();
|
||||
}
|
||||
let _ = render(&s);
|
||||
let n = s.doc().unwrap().doc.node(NodeId(g)).unwrap().clone();
|
||||
assert_eq!(n.appearance.effects.len(), 3);
|
||||
assert_eq!(n.appearance.effects[0].params["method"], json!("spin"), "defaults fill the rest");
|
||||
s.execute("effect.setParams", &json!({"index": 0, "ids": [g], "params": {"method": "zoom"}})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.node(NodeId(g)).unwrap().appearance.effects[0].params["method"], json!("zoom"));
|
||||
for _ in 0..4 {
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
assert!(s.doc().unwrap().doc.node(NodeId(g)).unwrap().appearance.effects.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_effect_changes_the_canvas_and_draws_the_same_twice() {
|
||||
for (id, params) in EFFECTS {
|
||||
let (mut s, g) = striped();
|
||||
let plain = render(&s);
|
||||
let params: Value = serde_json::from_str(params).unwrap();
|
||||
s.execute("effect.apply", &json!({"effect": id, "ids": [g], "params": params})).unwrap();
|
||||
let a = render(&s);
|
||||
assert!(mean_diff(&plain, &a) > 0.0005, "{id} shows: {}", mean_diff(&plain, &a));
|
||||
assert_eq!(a.pixels, render(&s).pixels, "{id} is deterministic");
|
||||
// The multithreaded pipeline draws the same.
|
||||
let mut mt = Renderer::new();
|
||||
mt.threads = 4;
|
||||
let b = mt.render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true);
|
||||
assert!(mean_diff(&a, &b) < 0.002, "{id}: multithreaded differs by {}", mean_diff(&a, &b));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spin_blur_reaches_past_the_corners() {
|
||||
let (mut s, g) = striped();
|
||||
s.execute("effect.apply", &json!({"effect": "blur.radial", "ids": [g], "params": {"amount": 60, "quality": "good"}})).unwrap();
|
||||
let img = render(&s);
|
||||
// Turned corners sweep beyond the square's sides (centre 100, 100; corner 70.7 pt out).
|
||||
let p = img.pixel(100, 46);
|
||||
assert!(p[1] < 250, "the arc of the corners reaches above the square: {p:?}");
|
||||
assert!(img.pixel(100, 20)[1] > 250, "but not beyond the corners' circle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_and_svg_write_the_effected_object_as_an_image() {
|
||||
for (id, params) in EFFECTS {
|
||||
let (mut s, g) = striped();
|
||||
let params: Value = serde_json::from_str(params).unwrap();
|
||||
s.execute("effect.apply", &json!({"effect": id, "ids": [g], "params": params})).unwrap();
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
// PDF: an image that reads back as the canvas shows it.
|
||||
let r = crate::cmd::rasterfx::export_pdf_with_report(&doc, &Default::default()).unwrap();
|
||||
assert!(r.warnings.is_empty(), "{id}: {:?}", r.warnings);
|
||||
let back = vectorcraft_pdf::import(&r.bytes).unwrap();
|
||||
let diff = mean_diff(&render(&s), &Renderer::new().render_region(&back, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true));
|
||||
assert!(diff < 0.03, "{id}: PDF reads back {:.2}% off", diff * 100.0);
|
||||
// SVG: the object becomes an image; a Gaussian Blur alone stays a live filter.
|
||||
let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap()["text"].as_str().unwrap().to_string();
|
||||
assert!(svg.contains("<image") && !svg.contains("<filter"), "{id}: {svg}");
|
||||
let flat = crate::cmd::rasterfx::flatten_pixel_effects(&doc).unwrap();
|
||||
assert!(matches!(flat.layers[0].children().unwrap()[0].kind, NodeKind::Image(_)), "{id}");
|
||||
}
|
||||
let (mut s, g) = striped();
|
||||
s.execute("effect.apply", &json!({"effect": "blur.gaussian", "ids": [g]})).unwrap();
|
||||
assert!(crate::cmd::rasterfx::flatten_pixel_effects(&s.doc().unwrap().doc).is_none());
|
||||
let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap()["text"].as_str().unwrap().to_string();
|
||||
assert!(svg.contains("feGaussianBlur") && !svg.contains("<image"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_appearance_makes_an_image() {
|
||||
let (mut s, g) = striped();
|
||||
s.execute("effect.apply", &json!({"effect": "sharpen.unsharpMask", "ids": [g]})).unwrap();
|
||||
s.execute("effect.expandAppearance", &json!({"ids": [g]})).unwrap();
|
||||
assert!(matches!(s.doc().unwrap().doc.node(NodeId(g)).unwrap().kind, NodeKind::Image(_)));
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
//! Public Affinity documents (`cargo xtask corpus --affinity`, pinned and sha256-verified) open
|
||||
//! natively, and their art renders like Affinity's own picture of them: every document embeds a
|
||||
//! thumbnail Affinity rendered when saving it. Each file's mean difference from that thumbnail
|
||||
//! (0–255 per channel, both flattened on white, at the thumbnail's size) must stay under its
|
||||
//! ceiling, which sits just above the difference measured when the importer landed.
|
||||
//!
|
||||
//! Skips when `corpus/affinity` is absent.
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// (file, ceiling). Measured differences when the importer landed are in the comments.
|
||||
const FILES: &[(&str, f64)] = &[
|
||||
("affinity3-mexican-guy.af", 3.0), // 1.80: brush strokes as plain strokes
|
||||
("affinity3-mexican-man.af", 3.5), // 2.24
|
||||
("affinity3-mexican-woman.af", 3.5), // 2.55
|
||||
("affinity3-playing-cards.af", 5.5), // 4.32: layer effects
|
||||
("designer-beer.afdesign", 4.0), // 2.90
|
||||
("designer-cactus.afdesign", 1.5), // 0.33
|
||||
("designer-car.afdesign", 1.5), // 0.40
|
||||
("designer-flowers.afdesign", 3.0), // 2.01
|
||||
("designer-lion-track.afdesign", 5.5), // 4.34
|
||||
("afdesignload-color.afdesign", 1.0), // 0.00
|
||||
("afdesignload-layer_mode.afdesign", 1.0), // 0.00
|
||||
("afdesignload-layer_test.afdesign", 1.0), // 0.00
|
||||
("afdesignload-margins.afdesign", 1.0), // 0.00
|
||||
("afdesignload-raster_test.afdesign", 2.0), // 0.72: pixel layer
|
||||
("afdesignload-revision_test.afdesign", 1.0),
|
||||
("afdesignload-shape_test.afdesign", 4.5), // 3.50: cloud and heart shapes as ellipses
|
||||
("afdesignload-slice_test.afdesign", 1.0),
|
||||
("afdesignload-test_path.afdesign", 1.5), // 0.26
|
||||
("jac21-SimpleLogo.afdesign", 1.5), // 0.19
|
||||
("jac21-SimpleLogoBanner.afdesign", 5.5), // 4.42: placed images in a Display P3 document
|
||||
("eviltwo-AssetStore.aftemplate", 1.0), // 0.00: artboards
|
||||
("leakcanary-vector_icon.afdesign", 3.5), // 2.33
|
||||
];
|
||||
|
||||
fn corpus() -> Option<PathBuf> {
|
||||
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../corpus/affinity");
|
||||
dir.join("SOURCES.md").exists().then_some(dir)
|
||||
}
|
||||
|
||||
/// Mean absolute difference of two RGBA8 images of one size, both composited on white.
|
||||
fn difference(a: &[u8], b: &[u8]) -> f64 {
|
||||
let white = |p: &[u8]| -> [f64; 3] {
|
||||
let al = f64::from(p[3]) / 255.0;
|
||||
[0, 1, 2].map(|i| f64::from(p[i]) * al + 255.0 * (1.0 - al))
|
||||
};
|
||||
let (mut sum, mut n) = (0.0, 0usize);
|
||||
for (p, q) in a.chunks_exact(4).zip(b.chunks_exact(4)) {
|
||||
let (x, y) = (white(p), white(q));
|
||||
sum += (0..3).map(|i| (x[i] - y[i]).abs()).sum::<f64>();
|
||||
n += 3;
|
||||
}
|
||||
sum / n.max(1) as f64
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_affinity_documents_render_like_their_affinity_thumbnails() {
|
||||
let Some(dir) = corpus() else {
|
||||
eprintln!("corpus/affinity is absent: run `cargo xtask corpus --affinity`");
|
||||
return;
|
||||
};
|
||||
let mut failures = Vec::new();
|
||||
for (name, ceiling) in FILES {
|
||||
let bytes = std::fs::read(dir.join(name)).unwrap();
|
||||
let loaded = vectorcraft_engine::cmd::fileio::load(name, &bytes).unwrap();
|
||||
assert!(!loaded.preview_only, "{name} opened as its preview: {:?}", loaded.warnings);
|
||||
let d = &loaded.doc;
|
||||
let thumb = image::load_from_memory(vectorcraft_affinity::preview(&bytes).unwrap().png).unwrap().to_rgba8();
|
||||
// The thumbnail shows the first page, or every artboard: compare the largest artboard
|
||||
// with its part of the thumbnail.
|
||||
let union = d.artboards.iter().map(|a| a.rect).reduce(|a, b| a.union(b)).unwrap();
|
||||
let scale = f64::from(thumb.width()) / union.width();
|
||||
let board = d.artboards.iter().max_by(|a, b| a.rect.area().total_cmp(&b.rect.area())).unwrap().rect;
|
||||
let crop = |v: f64| (v * scale).round().max(0.0) as u32;
|
||||
let (x, y) = (crop(board.x0 - union.x0), crop(board.y0 - union.y0));
|
||||
let (w, h) = (crop(board.width()).min(thumb.width() - x), crop(board.height()).min(thumb.height() - y));
|
||||
let expected = image::imageops::crop_imm(&thumb, x, y, w, h).to_image();
|
||||
let rendered = vectorcraft_render::Renderer::new().render_region(d, board, scale, false);
|
||||
let got = image::RgbaImage::from_raw(rendered.width, rendered.height, rendered.to_straight()).unwrap();
|
||||
let got = image::imageops::resize(&got, w, h, image::imageops::FilterType::Triangle);
|
||||
let diff = difference(expected.as_raw(), got.as_raw());
|
||||
eprintln!("{name}: {diff:.2} (ceiling {ceiling})");
|
||||
if diff > *ceiling {
|
||||
failures.push(format!("{name}: {diff:.2} > {ceiling}"));
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
}
|
||||
@@ -257,6 +257,9 @@ fn structured_junk() {
|
||||
("effect.apply", json!({"effect": "distort.zigZag", "params": {"size": 1e308, "ridges": 100}})),
|
||||
("effect.apply", json!({"effect": "distort.transform", "params": {"copies": 1e308}})),
|
||||
("effect.apply", json!({"effect": "stylize.dropShadow", "params": {"blur": 1e308, "opacity": -1}})),
|
||||
("effect.apply", json!({"effect": "blur.radial", "params": {"amount": 1e308, "method": 5, "quality": "best"}})),
|
||||
("effect.apply", json!({"effect": "blur.smart", "params": {"radius": 1e308, "threshold": -1e308, "quality": null}})),
|
||||
("effect.apply", json!({"effect": "sharpen.unsharpMask", "params": {"amount": -1, "radius": "1e999", "threshold": 1e308}})),
|
||||
("effect.apply", json!({"effect": "no.such.effect"})),
|
||||
("effect.remove", json!({"index": 99})),
|
||||
("effect.setParams", json!({"index": 0, "params": null})),
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
//! must load as an error or as a document that then renders and exports, without a panic; nor may
|
||||
//! bitmaps, PDF and text pasted from other apps, nor EMF and WMF pictures (damaged files, records
|
||||
//! of every kind with random contents) opened, placed or pasted, nor EPS and PostScript files
|
||||
//! (damaged ones, hostile programs) read by the PostScript interpreter.
|
||||
//! (damaged ones, hostile programs) read by the PostScript interpreter, nor Affinity documents
|
||||
//! (mutated object streams, archives and indexed PNG previews, hostile image dimensions).
|
||||
//!
|
||||
//! `PROPTEST_CASES=20000 cargo test -p vectorcraft-engine --test import_fuzz` runs a deeper search.
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
@@ -25,6 +26,244 @@ fn config() -> ProptestConfig {
|
||||
c
|
||||
}
|
||||
|
||||
// ---------- Affinity previews ----------
|
||||
|
||||
/// Original pixels in a synthetic container envelope, not an Affinity document writer.
|
||||
fn affinity_preview_sample() -> Vec<u8> {
|
||||
let im = image::RgbaImage::from_pixel(2, 1, image::Rgba([220, 40, 60, 255]));
|
||||
let mut png = Vec::new();
|
||||
im.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
let mut b = vec![0; 72];
|
||||
b[..4].copy_from_slice(vectorcraft_affinity::MAGIC);
|
||||
b[4..6].copy_from_slice(&12u16.to_le_bytes());
|
||||
b[8..12].copy_from_slice(b"nsrP");
|
||||
b[12..16].copy_from_slice(b"#Inf");
|
||||
b[24..32].copy_from_slice(&72u64.to_le_bytes());
|
||||
b[64..68].copy_from_slice(b"Prot");
|
||||
b.extend(b"\xff\xff\xff\xffThmb");
|
||||
b.extend(1u32.to_le_bytes());
|
||||
b.extend((png.len() as u32 + 13).to_le_bytes());
|
||||
b.extend(29u32.to_le_bytes());
|
||||
b.extend(0u32.to_le_bytes());
|
||||
b.extend((png.len() as u32).to_le_bytes());
|
||||
b.push(1);
|
||||
b.extend(png);
|
||||
b
|
||||
}
|
||||
|
||||
/// A synthetic native document exercising the reader's paths: curves (with live corners), every
|
||||
/// parametric shape it knows, a compound, gradients, a stroke, text, a mask and an artboard. Not
|
||||
/// an Affinity writer: the layout is the one the reader accepts.
|
||||
fn affinity_native_stream() -> Vec<u8> {
|
||||
use vectorcraft_affinity::synth::{self, F, tag};
|
||||
let f32s = |v: &[f32]| F::Struct(v.iter().flat_map(|x| x.to_le_bytes()).collect());
|
||||
let rec = |x: f64, y: f64, role: u8| {
|
||||
let mut r = x.to_le_bytes().to_vec();
|
||||
r.extend(y.to_le_bytes());
|
||||
r.extend([1, role]);
|
||||
r
|
||||
};
|
||||
let mut id = 100;
|
||||
let mut next = || {
|
||||
id += 1;
|
||||
id
|
||||
};
|
||||
let colour = |id: u32| F::Def(id, vec![tag(b"RGBA")], vec![(tag(b"_col"), f32s(&[0.2, 0.4, 0.6, 0.8]))]);
|
||||
let gradient = |a: u32, b: u32, c: u32, kind: u16| {
|
||||
F::Def(
|
||||
a,
|
||||
vec![tag(b"FDsc")],
|
||||
vec![
|
||||
(
|
||||
tag(b"FDeF"),
|
||||
F::Def(
|
||||
b,
|
||||
vec![tag(b"FilG")],
|
||||
vec![
|
||||
(tag(b"Type"), F::Enum(kind, 0)),
|
||||
(tag(b"Grad"), F::Obj(tag(b"Grad"), vec![(tag(b"Cols"), F::Shared(vec![colour(c), F::Ref(c)]))])),
|
||||
],
|
||||
),
|
||||
),
|
||||
(tag(b"FDeX"), F::F64s(vec![10.0, 0.0, 5.0, 0.0, 20.0, 5.0])),
|
||||
],
|
||||
)
|
||||
};
|
||||
let curve = F::Obj(
|
||||
tag(b"PCvD"),
|
||||
vec![(
|
||||
tag(b"Data"),
|
||||
F::Pos(vec![
|
||||
F::U8(0),
|
||||
F::U32(1),
|
||||
F::Bool(true),
|
||||
F::Records(18, vec![rec(0.0, 0.0, 0), rec(5.0, 0.0, 1), rec(10.0, 5.0, 2), rec(10.0, 10.0, 0), rec(0.0, 10.0, 0), rec(0.0, 0.0, 0)]),
|
||||
]),
|
||||
)],
|
||||
);
|
||||
let mut kids = vec![F::Def(
|
||||
next(),
|
||||
vec![tag(b"PCrv")],
|
||||
vec![(tag(b"Crvs"), curve.clone()), (tag(b"BFFl"), F::Shared(vec![gradient(next(), next(), next(), 0)]))],
|
||||
)];
|
||||
for (i, class) in [b"ShNR", b"ShpE", b"ShPy", b"ShSt", b"ShSS", b"ShPi", b"ShpT", b"ShTz", b"ShCl"].into_iter().enumerate() {
|
||||
let fields = vec![
|
||||
(tag(b"Side"), F::U32(6)),
|
||||
(tag(b"Smth"), F::Bool(i % 2 == 0)),
|
||||
(tag(b"Curv"), F::F32(0.5)),
|
||||
(tag(b"CTyp"), F::Raw(vec![0xaa, b'p', b'y', b'T', b'C', 4, 0, 0, 0, 0, 0, 0, 0, 4, 0, 4, 0, 4, 0])),
|
||||
(tag(b"ShCR"), f32s(&[0.25, 0.0, 0.0, 0.0])),
|
||||
(tag(b"AngE"), F::F32(1.0)),
|
||||
];
|
||||
kids.push(F::Def(
|
||||
next(),
|
||||
vec![tag(b"ShpN")],
|
||||
vec![
|
||||
(tag(b"Shpe"), F::Def(next(), vec![tag(class)], fields)),
|
||||
(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 40.0 + i as f64, 30.0])),
|
||||
(tag(b"Xfrm"), F::F64s(vec![1.0, 0.2, i as f64 * 10.0, 0.0, 1.0, 5.0])),
|
||||
(tag(b"BFFl"), F::Shared(vec![gradient(next(), next(), next(), (i % 4) as u16)])),
|
||||
(tag(b"AdCh"), F::Shared(vec![F::Def(next(), vec![tag(b"PCrv")], vec![(tag(b"Crvs"), curve.clone())])])),
|
||||
],
|
||||
));
|
||||
}
|
||||
kids.push(F::Def(
|
||||
next(),
|
||||
vec![tag(b"Comp")],
|
||||
vec![(tag(b"Chld"), F::Shared(vec![F::Def(next(), vec![tag(b"PCrv")], vec![(tag(b"Crvs"), curve.clone()), (tag(b"ComO"), F::Enum(2, 0))])]))],
|
||||
));
|
||||
let run = F::Def(
|
||||
next(),
|
||||
vec![tag(b"GAtt")],
|
||||
vec![(tag(b"Doub"), F::Raw(vec![0x8a, b'b', b'u', b'o', b'D', 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x28, 0x40]))],
|
||||
);
|
||||
let block = F::Def(
|
||||
next(),
|
||||
vec![tag(b"StBl")],
|
||||
vec![
|
||||
(tag(b"Glyp"), F::Obj(tag(b"GStr"), vec![(tag(b"Utf8"), F::Str("Hi\u{2029}there\0".into()))])),
|
||||
(
|
||||
tag(b"GAtt"),
|
||||
F::Obj(tag(b"GlAS"), vec![(tag(b"Runs"), F::Objs(tag(b"GlAR"), vec![vec![(tag(b"Indx"), F::I32(9)), (tag(b"Item"), run)]]))]),
|
||||
),
|
||||
],
|
||||
);
|
||||
kids.push(F::Def(
|
||||
next(),
|
||||
vec![tag(b"TxtA")],
|
||||
vec![
|
||||
(tag(b"StSt"), F::Def(next(), vec![tag(b"Stry")], vec![(tag(b"Blok"), F::Shared(vec![block]))])),
|
||||
(
|
||||
tag(b"TxtH"),
|
||||
F::Def(next(), vec![tag(b"ArFr")], vec![(tag(b"FrmB"), F::F64s(vec![0.0, -10.0, 30.0, 0.0])), (tag(b"ArtV"), F::F64(10.0))]),
|
||||
),
|
||||
],
|
||||
));
|
||||
let board = F::Def(
|
||||
next(),
|
||||
vec![tag(b"ShpN")],
|
||||
vec![
|
||||
(tag(b"ABEn"), F::Bool(true)),
|
||||
(tag(b"Shpe"), F::Def(next(), vec![tag(b"ShNR")], vec![])),
|
||||
(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 200.0, 100.0])),
|
||||
(tag(b"Chld"), F::Shared(kids)),
|
||||
],
|
||||
);
|
||||
synth::stream(&[
|
||||
(tag(b"UVCn"), F::Obj(tag(b"UVCn"), vec![(tag(b"UPPI"), F::F64(96.0))])),
|
||||
(
|
||||
tag(b"DocR"),
|
||||
F::Def(
|
||||
1,
|
||||
vec![tag(b"DocN")],
|
||||
vec![(tag(b"Chld"), F::Shared(vec![F::Def(2, vec![tag(b"Sprd")], vec![(tag(b"Chld"), F::Shared(vec![board]))])]))],
|
||||
),
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
fn affinity_native(stream: &[u8], method: vectorcraft_affinity::synth::Method) -> Vec<u8> {
|
||||
vectorcraft_affinity::synth::container(&[("doc.dat", stream, method)], None)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_native_affinity_sample_reaches_every_reader_path() {
|
||||
let bytes = affinity_native(&affinity_native_stream(), vectorcraft_affinity::synth::Method::Zstd);
|
||||
let l = vectorcraft_engine::cmd::fileio::load("x.af", &bytes).unwrap();
|
||||
assert!(!l.preview_only, "{:?}", l.warnings);
|
||||
let kinds = l.doc.layers.iter().flat_map(|l| l.children().into_iter().flatten()).count();
|
||||
assert!(kinds > 0);
|
||||
let all = format!("{:?}", l.doc.layers);
|
||||
for want in ["Path", "Text", "Compound"].iter().take(2) {
|
||||
assert!(all.contains(want), "{want} missing");
|
||||
}
|
||||
assert_eq!(l.doc.artboards.len(), 1);
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(config())]
|
||||
|
||||
#[test]
|
||||
fn affinity_native_documents_with_mutated_streams_render_and_export(
|
||||
cut in 0usize..4096,
|
||||
edits in prop::collection::vec((0usize..4096, any::<u8>()), 0..24),
|
||||
) {
|
||||
let mut stream = affinity_native_stream();
|
||||
let n = stream.len();
|
||||
for (at, b) in edits { stream[at % n] = b; }
|
||||
stream.truncate(cut.max(16));
|
||||
let bytes = affinity_native(&stream, vectorcraft_affinity::synth::Method::Stored);
|
||||
survive("Affinity native stream", || vectorcraft_engine::cmd::fileio::load("x.af", &bytes).ok().map(|l| l.doc))?;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn affinity_native_containers_with_mutated_bytes_render_and_export(
|
||||
edits in prop::collection::vec((0usize..8192, any::<u8>()), 1..16),
|
||||
method in 0u8..3,
|
||||
) {
|
||||
use vectorcraft_affinity::synth::Method;
|
||||
let m = [Method::Stored, Method::Zlib, Method::Zstd][usize::from(method)];
|
||||
let mut bytes = affinity_native(&affinity_native_stream(), m);
|
||||
let n = bytes.len();
|
||||
for (at, b) in edits { bytes[at % n] = b; }
|
||||
survive("Affinity native container", || vectorcraft_engine::cmd::fileio::load("x.af", &bytes).ok().map(|l| l.doc))?;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn affinity_garbage_never_panics(tail in prop::collection::vec(any::<u8>(), 0..2048)) {
|
||||
let mut bytes = vectorcraft_affinity::MAGIC.to_vec();
|
||||
bytes.extend(tail);
|
||||
survive("Affinity garbage", || vectorcraft_engine::cmd::fileio::load("x.af", &bytes).ok().map(|l| l.doc))?;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn affinity_mutated_previews_render_and_export_without_panicking(
|
||||
cut in 0usize..400,
|
||||
edits in prop::collection::vec((0usize..400, any::<u8>()), 0..16),
|
||||
) {
|
||||
let mut bytes = affinity_preview_sample();
|
||||
for (at, b) in edits {
|
||||
if let Some(byte) = bytes.get_mut(at) { *byte = b; }
|
||||
}
|
||||
bytes.truncate(cut);
|
||||
survive("Affinity preview", || vectorcraft_engine::cmd::fileio::load("x.af", &bytes).ok().map(|l| l.doc))?;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn affinity_placement_is_rejected_without_panicking(tail in prop::collection::vec(any::<u8>(), 0..2048)) {
|
||||
let mut bytes = vectorcraft_affinity::MAGIC.to_vec();
|
||||
bytes.extend(tail);
|
||||
let r = catch_quiet(|| {
|
||||
let mut s = vectorcraft_engine::Session::new();
|
||||
s.execute("file.new", &json!({"width":100,"height":100})).unwrap();
|
||||
let p = json!({"name":"renamed.png", "dataBase64":vectorcraft_format::base64_encode(&bytes)});
|
||||
for cmd in ["file.place", "file.place.info"] { assert!(s.execute(cmd, &p).is_err()); }
|
||||
assert!(s.execute("file.place.queue", &json!({"files":[p]})).is_err());
|
||||
});
|
||||
prop_assert!(r.is_ok(), "Affinity placement panicked: {:?}", r.err());
|
||||
}
|
||||
}
|
||||
|
||||
/// Import must not panic; whatever comes back must render and export without panicking either.
|
||||
fn survive(what: &str, import: impl FnOnce() -> Option<Document>) -> Result<(), TestCaseError> {
|
||||
let r = catch_quiet(|| {
|
||||
|
||||
@@ -185,7 +185,12 @@ fn polygon_rotation(corners: &[Point], step: f64) -> f64 {
|
||||
})
|
||||
.sum::<f64>()
|
||||
};
|
||||
[0.0, 90.0, 180.0, 270.0].into_iter().filter(|r| r % step == 0.0).min_by(|a, b| error(*a).total_cmp(&error(*b))).unwrap_or(0.0)
|
||||
// A candidate a whole number of periods from an earlier one is the same polygon (a hexagon at 270°
|
||||
// is the one at 90°) and scores the same up to rounding, so it is dropped: otherwise the platform's
|
||||
// `atan2` rounding picks between them (FreeBSD's libm gave 270° where Linux gives 90°).
|
||||
let candidates = [0.0, 90.0, 180.0, 270.0].into_iter().filter(|r| r % step == 0.0);
|
||||
let distinct = candidates.clone().enumerate().filter(|&(i, r)| !candidates.clone().take(i).any(|q| (r - q) % period == 0.0));
|
||||
distinct.map(|(_, r)| r).min_by(|a, b| error(*a).total_cmp(&error(*b))).unwrap_or(0.0)
|
||||
}
|
||||
|
||||
fn rectangle_rotation(corners: &[Point]) -> f64 {
|
||||
@@ -533,6 +538,31 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hexagon_rotation_never_depends_on_rounding_between_equivalent_angles() {
|
||||
// 0° and 180° (and 90° and 270°) are the same hexagon, so only 0° and 90° may come back, whatever
|
||||
// rounding does to the tied scores: on FreeBSD the tilted test above got 270°.
|
||||
for direction in [0.0_f64, 90.0] {
|
||||
for tilt in [-9.0_f64, -7.0, -3.0, 3.0, 7.0, 9.0] {
|
||||
for offset in [0.0, 1e-7, 0.1, 1e3, 1e6] {
|
||||
let corners: Vec<Point> = (0..6)
|
||||
.map(|i| {
|
||||
let a = (direction + tilt - 90.0).to_radians() + std::f64::consts::TAU * i as f64 / 6.0;
|
||||
Point::new(offset + 70.0 * a.cos(), offset + 70.0 * a.sin())
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(polygon_rotation(&corners, 90.0), direction, "tilt {tilt}, offset {offset}");
|
||||
let mut shifted = corners.clone();
|
||||
shifted.rotate_left(3);
|
||||
assert_eq!(polygon_rotation(&shifted, 90.0), direction, "tilt {tilt}, offset {offset}, from the opposite corner");
|
||||
}
|
||||
}
|
||||
}
|
||||
// Triangles have no such twin among the candidates: 0° and 180° stay distinct.
|
||||
let down: Vec<Point> = (0..3).map(|i| Point::new(0.0, 0.0) + Vec2::from_angle((90.0_f64 + 120.0 * i as f64).to_radians()) * 50.0).collect();
|
||||
assert_eq!(polygon_rotation(&down, 180.0), 180.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn square_fit_ignores_sampling_density_and_document_offset() {
|
||||
let mut stroke = wobbly(&[Point::new(0.0, 0.0), Point::new(100.0, 8.0), Point::new(94.0, 100.0), Point::new(10.0, 91.0)], 24, 4.0);
|
||||
|
||||
+203
-77
@@ -11,7 +11,12 @@
|
||||
//! - Gaussian Blur: the object painted inside a Gaussian filter layer;
|
||||
//! - Feather: like Gaussian Blur but clipped to the shape, so the edge fades inward;
|
||||
//! - Inner Glow: a blurred inverse silhouette (Edge) or the blurred silhouette (Center), clipped
|
||||
//! to the shape and painted above it.
|
||||
//! to the shape and painted above it;
|
||||
//! - Photoshop-style filters (Radial Blur, Smart Blur, Unsharp Mask…): the content is rendered
|
||||
//! offscreen and its pixels run through the content-changing effects in stack order (those
|
||||
//! filters, Gaussian Blur and Feather, see [`effects::pixel`]), then drawn back
|
||||
//! ([`Renderer::pixel_content`]). The result is cached per object while it and the zoom don't
|
||||
//! change.
|
||||
//!
|
||||
//! The same applies to one fill or stroke's own raster effects (around that item alone) and to
|
||||
//! type, images, symbol instances and live objects ([`Renderer::draw_object_fx`]): their art is
|
||||
@@ -132,8 +137,12 @@ pub(crate) fn visual_bounds(n: &Node) -> Option<Rect> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let o = n.appearance.outset() + effects::outset(&n.appearance.effects);
|
||||
r.map(|r| r.inflate(o, o))
|
||||
let s = n.appearance.outset();
|
||||
r.map(|r| {
|
||||
let r = r.inflate(s, s);
|
||||
let o = effects::outset(&n.appearance.effects, r);
|
||||
r.inflate(o, o)
|
||||
})
|
||||
}
|
||||
|
||||
/// Bounds used for culling: like `Node::visual_bounds`, but aware of live effects anywhere in
|
||||
@@ -151,7 +160,7 @@ pub(crate) fn cull_bounds(n: &Node) -> Option<Rect> {
|
||||
} else {
|
||||
art.children()?.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, cull_bounds(c)))
|
||||
}?;
|
||||
let o = effects::outset(&n.appearance.effects);
|
||||
let o = effects::outset(&n.appearance.effects, b);
|
||||
Some(b.inflate(o, o))
|
||||
}
|
||||
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => {
|
||||
@@ -161,7 +170,7 @@ pub(crate) fn cull_bounds(n: &Node) -> Option<Rect> {
|
||||
// The reshaped art (a symbol's art needs the document: its instance box stands in).
|
||||
_ if has_object_fx(n) => {
|
||||
let b = effects::reshape(n, None).and_then(|r| r.visual_bounds()).or_else(|| n.visual_bounds())?;
|
||||
let o = effects::outset(&n.appearance.effects);
|
||||
let o = effects::outset(&n.appearance.effects, b);
|
||||
Some(b.inflate(o, o))
|
||||
}
|
||||
_ => n.visual_bounds(),
|
||||
@@ -187,6 +196,56 @@ fn pcolor(ink: Ink, c: &vectorcraft_doc::color::Color) -> peniko::Color {
|
||||
peniko::Color::new([r, g, b, 1.0])
|
||||
}
|
||||
|
||||
/// Most pixels the raster of [`Renderer::pixel_content`] holds (a 4K screen's): larger content is
|
||||
/// cropped to the view when its effects only look nearby, else rendered at a lower resolution.
|
||||
const PIXEL_BUDGET: f64 = 8.4e6;
|
||||
|
||||
/// The effects [`Renderer::pixel_content`] runs on the content's pixels (those changing the
|
||||
/// content itself), in stack order.
|
||||
fn content_changing(fx: &&RasterFx) -> bool {
|
||||
matches!(fx, RasterFx::Feather { .. } | RasterFx::GaussianBlur { .. } | RasterFx::Pixel(_))
|
||||
}
|
||||
|
||||
/// How far (document units) from a pixel the content deciding it after `fx` lies; `None` when
|
||||
/// anywhere in the object.
|
||||
fn looks_around(fx: &RasterFx) -> Option<f64> {
|
||||
match fx {
|
||||
RasterFx::Feather { radius } | RasterFx::GaussianBlur { radius } => Some(1.5 * radius),
|
||||
RasterFx::Pixel(p) => p.reach(),
|
||||
_ => Some(0.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// The content of an object with Photoshop-style effects as filtered pixels, drawn at
|
||||
/// (`anchor`'s view position + `rel`) scaled by 1 / `k` (see [`Renderer::pixel_content`]).
|
||||
pub(crate) struct PixelEntry {
|
||||
node: Node,
|
||||
linear: [u64; 4],
|
||||
anchor: vectorcraft_geom::Point,
|
||||
rel: (f64, f64),
|
||||
k: f64,
|
||||
image: std::sync::Arc<vello_cpu::Pixmap>,
|
||||
pub(crate) stamp: u64,
|
||||
}
|
||||
|
||||
/// The four linear coefficients of `view` (zoom and rotation, not the pan), as cache keys.
|
||||
fn linear_key(view: Affine) -> [u64; 4] {
|
||||
let c = view.as_coeffs();
|
||||
[c[0].to_bits(), c[1].to_bits(), c[2].to_bits(), c[3].to_bits()]
|
||||
}
|
||||
|
||||
/// Draw `image` with its top-left at (`x`, `y`) device pixels, `1 / k` device pixels a pixel.
|
||||
fn draw_scaled(ctx: &mut RenderContext, image: std::sync::Arc<vello_cpu::Pixmap>, (x, y): (f64, f64), k: f64) {
|
||||
let (w, h) = (image.width() as f64, image.height() as f64);
|
||||
ctx.set_transform(Affine::translate((x, y)) * Affine::scale(1.0 / k));
|
||||
// On whole pixels at full size the cheapest sampler is exact.
|
||||
let quality = if k < 1.0 { peniko::ImageQuality::Medium } else { peniko::ImageQuality::Low };
|
||||
let sampler = peniko::ImageSampler { quality, ..Default::default() };
|
||||
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(image), sampler });
|
||||
ctx.fill_rect(&Rect::new(0.0, 0.0, w, h));
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
}
|
||||
|
||||
/// A cached shadow / glow raster: premultiplied, already tinted and faded, positioned relative to
|
||||
/// the view-space position of `anchor` (a document point).
|
||||
pub(crate) struct ShadowEntry {
|
||||
@@ -199,73 +258,6 @@ pub(crate) struct ShadowEntry {
|
||||
pub(crate) stamp: u64,
|
||||
}
|
||||
|
||||
/// Box sizes approximating a Gaussian of `sigma` with three box blurs.
|
||||
fn gauss_boxes(sigma: f64) -> [usize; 3] {
|
||||
let n = 3.0;
|
||||
let w_ideal = (12.0 * sigma * sigma / n + 1.0).sqrt();
|
||||
let mut wl = w_ideal.floor() as i64;
|
||||
if wl % 2 == 0 {
|
||||
wl -= 1;
|
||||
}
|
||||
let wu = wl + 2;
|
||||
let m_ideal = (12.0 * sigma * sigma - n * (wl * wl) as f64 - 4.0 * n * wl as f64 - 3.0 * n) / (-4.0 * wl as f64 - 4.0);
|
||||
let m = m_ideal.round() as i64;
|
||||
let r = |i: i64| (((if i < m { wl } else { wu }) - 1) / 2).max(0) as usize;
|
||||
[r(0), r(1), r(2)]
|
||||
}
|
||||
|
||||
/// One horizontal box-blur pass of radius `r` over rows of `w` (running sum, edges as zero).
|
||||
fn box_blur_h(src: &[f32], dst: &mut [f32], w: usize, h: usize, r: usize) {
|
||||
if r == 0 {
|
||||
dst.copy_from_slice(src);
|
||||
return;
|
||||
}
|
||||
let norm = 1.0 / (2 * r + 1) as f32;
|
||||
for y in 0..h {
|
||||
let row = &src[y * w..(y + 1) * w];
|
||||
let out = &mut dst[y * w..(y + 1) * w];
|
||||
let mut acc: f32 = row.iter().take(r + 1).sum();
|
||||
for x in 0..w {
|
||||
out[x] = acc * norm;
|
||||
if x + r + 1 < w {
|
||||
acc += row[x + r + 1];
|
||||
}
|
||||
if x >= r {
|
||||
acc -= row[x - r];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn transpose(src: &[f32], dst: &mut [f32], w: usize, h: usize) {
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
dst[x * h + y] = src[y * w + x];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Gaussian-blur an alpha plane in place (three box passes per axis).
|
||||
fn blur_alpha(a: &mut Vec<f32>, w: usize, h: usize, sigma: f64) {
|
||||
if sigma < 0.2 {
|
||||
return;
|
||||
}
|
||||
let boxes = gauss_boxes(sigma);
|
||||
let mut tmp = vec![0.0; a.len()];
|
||||
for r in boxes {
|
||||
box_blur_h(a, &mut tmp, w, h, r);
|
||||
std::mem::swap(a, &mut tmp);
|
||||
}
|
||||
transpose(a, &mut tmp, w, h);
|
||||
std::mem::swap(a, &mut tmp);
|
||||
for r in boxes {
|
||||
box_blur_h(a, &mut tmp, h, w, r);
|
||||
std::mem::swap(a, &mut tmp);
|
||||
}
|
||||
transpose(a, &mut tmp, h, w);
|
||||
std::mem::swap(a, &mut tmp);
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
/// Drop shadow / outer glow `fx` (shadow-cache slot `i` of `n`) from a cached raster:
|
||||
/// the silhouette is rendered once into a small crop, blurred and tinted on the CPU, and reused
|
||||
@@ -287,8 +279,7 @@ impl Renderer {
|
||||
RasterFx::OuterGlow { mode, opacity, blur, color } => (*mode, *opacity, 0.0, 0.0, *blur, *color),
|
||||
_ => return false,
|
||||
};
|
||||
let c = f.view.as_coeffs();
|
||||
let linear = [c[0].to_bits(), c[1].to_bits(), c[2].to_bits(), c[3].to_bits()];
|
||||
let linear = linear_key(f.view);
|
||||
let key = (n as *const Node as usize, i, f.ink);
|
||||
let stamp = self.stamp;
|
||||
let hit = self.shadows.get_mut(&key).filter(|e| e.linear == linear && e.node == *n);
|
||||
@@ -317,7 +308,7 @@ impl Renderer {
|
||||
off.render(&mut pm, &mut self.resources);
|
||||
let (wu, hu) = (w as usize, h as usize);
|
||||
let mut a: Vec<f32> = pm.data().iter().map(|p| p.a as f32).collect();
|
||||
blur_alpha(&mut a, wu, hu, blur / 2.0 / f.px);
|
||||
effects::pixel::blur_plane(&mut a, wu, hu, blur / 2.0 / f.px);
|
||||
let [cr, cg, cb] = f.ink.rgb(&color);
|
||||
let tint = |v: f32, ch: f32| (v * ch).round().clamp(0.0, 255.0) as u8;
|
||||
for (px, av) in pm.data_mut().iter_mut().zip(&a) {
|
||||
@@ -504,7 +495,7 @@ impl Renderer {
|
||||
c.pop_layer();
|
||||
});
|
||||
}
|
||||
// The content itself (blurred / feathered).
|
||||
// The content itself (blurred / feathered / filtered).
|
||||
let blur: f64 = rfx
|
||||
.iter()
|
||||
.map(|fx| match fx {
|
||||
@@ -512,7 +503,9 @@ impl Renderer {
|
||||
_ => 0.0,
|
||||
})
|
||||
.sum();
|
||||
if blur > 0.0 {
|
||||
if rfx.iter().any(|fx| matches!(fx, RasterFx::Pixel(_))) {
|
||||
self.pixel_content(ctx, f, content, rfx, paint);
|
||||
} else if blur > 0.0 {
|
||||
self.with_filters(ctx, f, reach, blur, None, |r, c, fr, _| {
|
||||
let mut layers = 0;
|
||||
for fx in rfx {
|
||||
@@ -580,6 +573,105 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Paint `content` through the content-changing effects of `rfx` (Gaussian Blur, Feather and the
|
||||
/// Photoshop-style filters) in stack order on its pixels: rendered offscreen over all it can
|
||||
/// reach, filtered on the CPU and drawn back. The raster holds at most [`PIXEL_BUDGET`] pixels:
|
||||
/// larger content is cropped to the view when every effect only looks nearby, else rendered
|
||||
/// at a lower resolution. A whole raster at full resolution is cached while the object and the
|
||||
/// zoom stay the same (panning only moves it).
|
||||
fn pixel_content(
|
||||
&mut self,
|
||||
ctx: &mut RenderContext,
|
||||
f: &Frame,
|
||||
content: &Content,
|
||||
rfx: &[RasterFx],
|
||||
paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame),
|
||||
) {
|
||||
let chain: Vec<&RasterFx> = rfx.iter().filter(content_changing).collect();
|
||||
// Where the result can paint: each effect spreads what the ones before it painted.
|
||||
let reach = chain.iter().fold(content.reach, |r, fx| {
|
||||
let o = fx.outset(r);
|
||||
r.inflate(o, o)
|
||||
});
|
||||
let full = f.view.transform_rect_bbox(reach).inflate(2.0, 2.0);
|
||||
let (sw, sh) = (ctx.width() as f64, ctx.height() as f64);
|
||||
if !(full.area().is_finite() && full.x1 > 0.0 && full.y1 > 0.0 && full.x0 < sw && full.y0 < sh) {
|
||||
return;
|
||||
}
|
||||
let fits = full.area() <= PIXEL_BUDGET;
|
||||
// Device pixels from a pixel to the content deciding it (`None`: anywhere).
|
||||
let margin = chain.iter().try_fold(2.0, |m, fx| looks_around(fx).map(|d| m + d / f.px));
|
||||
let region = match margin {
|
||||
Some(m) if !fits => full.intersect(Rect::new(0.0, 0.0, sw, sh).inflate(m, m)),
|
||||
_ => full,
|
||||
};
|
||||
let (x0, y0) = (region.x0.floor(), region.y0.floor());
|
||||
let (rw, rh) = ((region.x1 - x0).max(1.0), (region.y1 - y0).max(1.0));
|
||||
let side = u16::MAX as f64;
|
||||
let k = (PIXEL_BUDGET / (rw * rh)).sqrt().min(side / rw).min(side / rh).min(1.0);
|
||||
let (w, h) = ((rw * k).ceil().clamp(1.0, side), (rh * k).ceil().clamp(1.0, side));
|
||||
// The cached raster, when the whole content was rendered at full size at this zoom.
|
||||
let linear = linear_key(f.view);
|
||||
let key = content.cache.filter(|_| fits && k >= 1.0).map(|slot| (content.node as *const Node as usize, slot, f.ink));
|
||||
let stamp = self.stamp;
|
||||
if let Some(e) = key.and_then(|key| self.pixel_fx.get_mut(&key)).filter(|e| e.linear == linear && e.node == *content.node) {
|
||||
e.stamp = stamp;
|
||||
let p = f.view * e.anchor;
|
||||
let (image, at, k) = (e.image.clone(), ((p.x + e.rel.0).round(), (p.y + e.rel.1).round()), e.k);
|
||||
draw_scaled(ctx, image, at, k);
|
||||
return;
|
||||
}
|
||||
let (wu, hu) = (w as u16, h as u16);
|
||||
let view = Affine::scale(k) * Affine::translate((-x0, -y0)) * f.view;
|
||||
let to_doc = view.inverse();
|
||||
let px = f.px / k;
|
||||
let frame = Frame { mt: false, view, visible: to_doc.transform_rect_bbox(Rect::new(0.0, 0.0, w, h)), px, ..*f };
|
||||
let mut off = f.offscreen_context(wu, hu);
|
||||
self.inside_layer(|r| paint(r, &mut off, &frame));
|
||||
off.flush();
|
||||
let mut pm = vello_cpu::Pixmap::new(wu, hu);
|
||||
off.render(&mut pm, &mut self.resources);
|
||||
let space = effects::PixelSpace { to_doc, px, center: content.reach.center() };
|
||||
let (wz, hz) = (wu as usize, hu as usize);
|
||||
for fx in chain {
|
||||
match fx {
|
||||
RasterFx::GaussianBlur { radius } => effects::pixel::gaussian_rgba(pm.data_as_u8_slice_mut(), wz, hz, radius / 2.0 / px),
|
||||
RasterFx::Feather { radius } => {
|
||||
effects::pixel::gaussian_rgba(pm.data_as_u8_slice_mut(), wz, hz, radius / 2.0 / px);
|
||||
if let Some((g, rule)) = content.outline {
|
||||
self.clip_pixels(&mut pm, &frame, g, rule);
|
||||
}
|
||||
}
|
||||
RasterFx::Pixel(p) => p.apply(pm.data_as_u8_slice_mut(), wz, hz, &space),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let image = std::sync::Arc::new(pm);
|
||||
if let Some(key) = key {
|
||||
let anchor = content.reach.origin();
|
||||
let p = f.view * anchor;
|
||||
let rel = (x0 - p.x, y0 - p.y);
|
||||
self.pixel_fx.insert(key, PixelEntry { node: content.node.clone(), linear, anchor, rel, k, image: image.clone(), stamp });
|
||||
}
|
||||
draw_scaled(ctx, image, (x0, y0), k);
|
||||
}
|
||||
|
||||
/// Keep `pm`'s pixels only inside `g` filled by `rule` as `frame` draws it (Feather's clip).
|
||||
fn clip_pixels(&mut self, pm: &mut vello_cpu::Pixmap, frame: &Frame, g: &BezPath, rule: FillRule) {
|
||||
let mut m = frame.offscreen_context(pm.width(), pm.height());
|
||||
m.set_transform(frame.view);
|
||||
m.set_fill_rule(fill_rule(rule));
|
||||
m.set_paint(peniko::Color::BLACK);
|
||||
m.fill_path(g);
|
||||
m.flush();
|
||||
let mut mask = vello_cpu::Pixmap::new(pm.width(), pm.height());
|
||||
m.render(&mut mask, &mut self.resources);
|
||||
for (p, c) in pm.data_mut().iter_mut().zip(mask.data()) {
|
||||
let keep = |v: u8| ((v as u32 * c.a as u32 + 127) / 255) as u8;
|
||||
*p = vello_cpu::color::PremulRgba8 { r: keep(p.r), g: keep(p.g), b: keep(p.b), a: keep(p.a) };
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `draw`, which pushes filter layers. Single-threaded contexts draw directly and `draw`
|
||||
/// applies `composite` (blend mode, opacity) on its filter layer. On a multithreaded context
|
||||
/// the layer is drawn into an offscreen single-threaded context covering `reach` (document
|
||||
@@ -885,4 +977,38 @@ mod tests {
|
||||
assert!(worst <= 4, "{name}: max channel difference {worst}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn photoshop_filters_draw_and_follow_a_pan_from_the_cache() {
|
||||
let d = doc_with(Rect::new(30.0, 30.0, 70.0, 70.0), Color::BLACK, vec![("blur.radial", json!({"amount": 40, "quality": "draft"}))]);
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
let mut r = Renderer::new();
|
||||
let a = r.render(&d, 100, 100, Affine::IDENTITY, &opts);
|
||||
assert!(lum(a.pixel(31, 31)) > 60, "the spin softens the corners: {:?}", a.pixel(31, 31));
|
||||
assert_eq!(lum(a.pixel(50, 50)), 0, "the centre stays");
|
||||
assert!(lum(a.pixel(50, 28)) < WHITE, "turned corners reach past the sides");
|
||||
// Panned: the cached raster moves with the view.
|
||||
let b = r.render(&d, 100, 100, Affine::translate((10.0, 3.0)), &opts);
|
||||
for (x, y) in [(31, 31), (50, 28), (69, 50), (40, 66)] {
|
||||
assert_eq!(a.pixel(x, y), b.pixel(x + 10, y + 3), "({x}, {y})");
|
||||
}
|
||||
// A fresh renderer draws the panned view the same.
|
||||
let c = Renderer::new().render(&d, 100, 100, Affine::translate((10.0, 3.0)), &opts);
|
||||
assert_eq!(b.pixels, c.pixels);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn photoshop_filters_on_content_larger_than_the_budget() {
|
||||
// Zoomed in 200×: the 40 pt square covers 8000 × 8000 px around a 200 × 200 view.
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
let view = Affine::translate((100.0, 100.0)) * Affine::scale(200.0) * Affine::translate((-70.0, -50.0));
|
||||
for (id, p) in [("sharpen.unsharpMask", json!({"amount": 200})), ("blur.smart", json!({})), ("blur.radial", json!({"quality": "draft"}))] {
|
||||
let d = doc_with(Rect::new(30.0, 30.0, 70.0, 70.0), Color::rgb(0.2, 0.4, 0.8), vec![(id, p)]);
|
||||
let img = Renderer::new().render(&d, 200, 200, view, &opts);
|
||||
// The view shows the square's right side half way down: inside left, outside right.
|
||||
assert!(lum(img.pixel(40, 100)) < 600, "{id}: inside {:?}", img.pixel(40, 100));
|
||||
// (Smart Blur's wide samples read a blurred copy: a faint fringe under its threshold.)
|
||||
assert!(lum(img.pixel(190, 100)) >= WHITE - 3 * 25, "{id}: outside {:?}", img.pixel(190, 100));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -316,6 +316,9 @@ pub struct Renderer {
|
||||
live: live::LiveCache,
|
||||
/// Blurred, tinted drop shadow / outer glow rasters per object and effect (see `fx`).
|
||||
shadows: PtrMap<(usize, usize, Ink), fx::ShadowEntry>,
|
||||
/// Objects' content run through Photoshop-style effects, per object and content slot (see
|
||||
/// `fx`): kept only while drawn.
|
||||
pixel_fx: PtrMap<(usize, usize, Ink), fx::PixelEntry>,
|
||||
/// Whether the group being drawn is a knockout group (what its neutral children inherit).
|
||||
knockout: bool,
|
||||
/// Address of the knockout-group element being drawn as its knockout shape: at full object
|
||||
@@ -421,6 +424,7 @@ impl Renderer {
|
||||
stats: FrameStats::default(),
|
||||
brushes: Default::default(),
|
||||
shadows: PtrMap::default(),
|
||||
pixel_fx: PtrMap::default(),
|
||||
live: live::LiveCache::default(),
|
||||
knockout: false,
|
||||
shape_of: 0,
|
||||
@@ -491,6 +495,8 @@ impl Renderer {
|
||||
if self.shadows.len() > 256 {
|
||||
self.shadows.retain(|_, e| g - e.stamp <= 3);
|
||||
}
|
||||
// Filtered rasters are large: only those of the last frame stay.
|
||||
self.pixel_fx.retain(|_, e| e.stamp == g);
|
||||
if !inks {
|
||||
proof::post(&mut pixels, opts);
|
||||
}
|
||||
|
||||
@@ -584,6 +584,7 @@ impl Rules<'_> {
|
||||
}
|
||||
RasterFx::GaussianBlur { radius } => filter = Some(format!("blur({})", self.len(radius / 2.0))),
|
||||
RasterFx::InnerGlow { .. } | RasterFx::Feather { .. } => art.unsupported("inner glows or feathering"),
|
||||
RasterFx::Pixel(_) => art.unsupported("pixel effects"),
|
||||
}
|
||||
}
|
||||
if !shadows.is_empty() {
|
||||
|
||||
@@ -1486,7 +1486,9 @@ impl Writer<'_> {
|
||||
}
|
||||
|
||||
/// Raster effects (drop shadow, glows, Gaussian blur, feather) as SVG filters: one `<g filter>`
|
||||
/// per effect, the first effect innermost (the reference app's stacking order).
|
||||
/// per effect, the first effect innermost (the reference app's stacking order). Photoshop-style
|
||||
/// filters have no SVG equivalent: they are left out with a warning (exports from the app turn
|
||||
/// such objects into images first).
|
||||
fn filtered(&mut self, n: &Node) {
|
||||
let mut inner = n.clone();
|
||||
inner.appearance.effects.retain(|e| !vectorcraft_effects::is_raster(&e.id));
|
||||
@@ -1506,9 +1508,15 @@ impl Writer<'_> {
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
let reach: f64 = fx.iter().map(RasterFx::outset).sum::<f64>() + 2.0;
|
||||
let region = n.visual_bounds().map(|b| self.xf.transform_rect_bbox(b).inflate(reach, reach));
|
||||
let bounds = n.visual_bounds();
|
||||
let reach: f64 = fx.iter().map(|f| f.outset(bounds.unwrap_or_default())).sum::<f64>() + 2.0;
|
||||
let region = bounds.map(|b| self.xf.transform_rect_bbox(b).inflate(reach, reach));
|
||||
let mut opened = 0;
|
||||
for f in fx.iter().rev() {
|
||||
if matches!(f, RasterFx::Pixel(_)) {
|
||||
self.warn("SVG has no pixel effects such as Radial Blur, Smart Blur or Unsharp Mask: they are left out");
|
||||
continue;
|
||||
}
|
||||
let fid = self.fresh_id("filter");
|
||||
let region_attr = match region {
|
||||
Some(r) => format!(
|
||||
@@ -1557,6 +1565,7 @@ impl Writer<'_> {
|
||||
sd(*radius)
|
||||
),
|
||||
RasterFx::GaussianBlur { radius } => format!("<feGaussianBlur in=\"SourceGraphic\" stdDeviation=\"{}\"/>", sd(*radius)),
|
||||
RasterFx::Pixel(_) => continue,
|
||||
};
|
||||
// Filters composite normally: a shadow's or glow's other blend mode is recorded for import.
|
||||
let mode = match f {
|
||||
@@ -1570,8 +1579,9 @@ impl Writer<'_> {
|
||||
self.def(1, &format!("<filter id=\"{fid}\"{region_attr}{mode} color-interpolation-filters=\"sRGB\">{body}</filter>"));
|
||||
self.line(&format!("<g filter=\"url(#{fid})\">"));
|
||||
self.depth += 1;
|
||||
opened += 1;
|
||||
}
|
||||
fx.len()
|
||||
opened
|
||||
}
|
||||
|
||||
fn close_filters(&mut self, opened: usize) {
|
||||
|
||||
@@ -864,6 +864,15 @@ pub fn dispatch(app: &mut VectorcraftApp, ev: &PointerEvent, view: vectorcraft_e
|
||||
{
|
||||
v.artboard = i;
|
||||
}
|
||||
// A press with a selection tool inside an artboard makes it the active one (#693), as the
|
||||
// Artboard tool's does: Paste in Place and in Front or Back then paste onto it.
|
||||
if ev.kind == PointerKind::Down
|
||||
&& vectorcraft_tools::catalog::is_selection_tool(app.session.tool_id())
|
||||
&& let Some(i) = app.session.active().and_then(|d| d.doc.artboard_at(ev.pos))
|
||||
&& let Some(v) = app.view_mut()
|
||||
{
|
||||
v.artboard = i;
|
||||
}
|
||||
apply_requests(app, r);
|
||||
}
|
||||
|
||||
@@ -2051,6 +2060,34 @@ mod tests {
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
/// A click with the Selection tool inside an artboard makes it the active one, and Paste in
|
||||
/// Place then pastes onto it, where the objects were on their own artboard (#693).
|
||||
#[test]
|
||||
fn a_click_activates_its_artboard_and_paste_in_place_goes_onto_it() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 3})).unwrap();
|
||||
let boards: Vec<_> = app.session.active().unwrap().doc.artboards.iter().map(|a| a.rect).collect();
|
||||
app.session.execute("shape.rectangle", &json!({"x": boards[0].x0 + 10.0, "y": boards[0].y0 + 15.0, "width": 20, "height": 20})).unwrap();
|
||||
app.run("edit.copy", json!({})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
app.select_tool("selection");
|
||||
let view = app.view_info();
|
||||
let at = boards[2].center();
|
||||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||
dispatch(&mut app, &PointerEvent { kind, pos: at, mods: Default::default(), pressure: 1.0 }, view);
|
||||
}
|
||||
assert_eq!(app.view().unwrap().artboard, 2, "the clicked artboard is the active one");
|
||||
let ids: Vec<vectorcraft_doc::NodeId> = app.run("edit.pasteInPlace", json!({})).unwrap()["ids"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|v| vectorcraft_doc::NodeId(v.as_u64().unwrap()))
|
||||
.collect();
|
||||
let b = app.session.active().unwrap().doc.bounds_of(&ids, false).unwrap();
|
||||
assert_eq!((b.x0, b.y0), (boards[2].x0 + 10.0, boards[2].y0 + 15.0));
|
||||
}
|
||||
|
||||
/// Enable Touch Gestures (#585): a two-finger tap undoes and a three-finger one redoes, and the
|
||||
/// first finger's press (the pointer egui makes of it) draws nothing. Off, a tap does nothing.
|
||||
#[test]
|
||||
|
||||
@@ -106,17 +106,21 @@ fn double_arrow(p: &mut Ink, o: Pos2, dir: egui::Vec2) {
|
||||
}
|
||||
}
|
||||
|
||||
/// A curved double arrow over the hotspot: an arc with an arrowhead at each end, pointing on along
|
||||
/// the arc (#701).
|
||||
fn rotate(p: &mut Ink, o: Pos2) {
|
||||
let pts: Vec<Pos2> = (0..=10)
|
||||
.map(|i| {
|
||||
let a = std::f32::consts::PI * (0.15 + 0.7 * i as f32 / 10.0);
|
||||
o + vec2(a.cos() * 9.0, -a.sin() * 9.0)
|
||||
})
|
||||
.collect();
|
||||
p.add(Shape::line(pts.clone(), Stroke::new(3.0, HALO)));
|
||||
p.add(Shape::line(pts.clone(), Stroke::new(1.2, INK)));
|
||||
for end in [pts[0], pts[pts.len() - 1]] {
|
||||
poly(p, vec![end + vec2(-3.0, -1.0), end + vec2(3.0, -1.0), end + vec2(0.0, 4.0)], INK, INK);
|
||||
const R: f32 = 8.0;
|
||||
let (a0, a1) = (std::f32::consts::PI * 0.12, std::f32::consts::PI * 0.88);
|
||||
let at = |a: f32| o + vec2(a.cos() * R, -a.sin() * R);
|
||||
let pts: Vec<Pos2> = (0..=12).map(|i| at(a0 + (a1 - a0) * i as f32 / 12.0)).collect();
|
||||
p.add(Shape::line(pts.clone(), Stroke::new(3.2, HALO)));
|
||||
p.add(Shape::line(pts, Stroke::new(1.3, INK)));
|
||||
// Each head points away from the arc, along its tangent there.
|
||||
for (a, away) in [(a0, -1.0f32), (a1, 1.0)] {
|
||||
let t = vec2(-a.sin(), -a.cos()) * away;
|
||||
let n = vec2(-t.y, t.x);
|
||||
let (end, tip) = (at(a) - t * 1.5, at(a) + t * 4.5);
|
||||
poly(p, vec![tip, end + n * 1.8, end - n * 1.8], INK, INK);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ pub(super) const SPEC: DialogSpec = DialogSpec {
|
||||
body: |app, ui, d| {
|
||||
let command = d.str("__command");
|
||||
let lengths = lengths(&command);
|
||||
form::param_fields(ui, d, &|k| lengths.contains(&k), &|k| choices(&command, k), app.session.general_unit());
|
||||
form::param_fields(ui, d, &|k| lengths.contains(&k), &|k| choices(&command, k), &|_| 0, app.session.general_unit());
|
||||
false
|
||||
},
|
||||
confirm,
|
||||
|
||||
@@ -131,7 +131,11 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let changed = match vectorcraft_plugins::effect::installed(&id) {
|
||||
Some(plugin) => form::schema_fields(ui, d, &plugin.manifest().params),
|
||||
None if vectorcraft_effects::is_adjustment(&id) => adjust_fields(ui, d),
|
||||
None => form::param_fields(ui, d, &|k| vectorcraft_effects::is_length(&id, k, relative), &|_| None, app.session.general_unit()),
|
||||
None => {
|
||||
let doc = vectorcraft_effects::effect_info(&id).map(|e| e.params).unwrap_or_default();
|
||||
let unit = app.session.general_unit();
|
||||
form::param_fields(ui, d, &|k| vectorcraft_effects::is_length(&id, k, relative), &|k| choices(&id, k), &|k| doc_rank(doc, k), unit)
|
||||
}
|
||||
};
|
||||
ui.add_space(6.0);
|
||||
let mut pv = d.bool("preview");
|
||||
@@ -148,6 +152,26 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// The parameters of the effect dialogs that pick one of some values, as (label, value).
|
||||
fn choices(effect: &str, key: &str) -> Option<form::Choices> {
|
||||
match (effect, key) {
|
||||
("blur.radial", "method") => Some(&[("Spin", "spin"), ("Zoom", "zoom")]),
|
||||
("blur.radial", "quality") => Some(&[("Draft", "draft"), ("Good", "good"), ("Best", "best")]),
|
||||
("blur.smart", "quality") => Some(&[("Low", "low"), ("Medium", "medium"), ("High", "high")]),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Where parameter `key` comes in the effect's parameter documentation `doc` (`{radius: …,
|
||||
/// threshold: …}`), which lists them in its dialog's order; the end when it isn't there.
|
||||
pub(super) fn doc_rank(doc: &str, key: &str) -> usize {
|
||||
let starts = |at: usize| doc.get(..at).and_then(|s| s.chars().last()).is_some_and(|c| c == '{' || c == ' ');
|
||||
doc.match_indices(key)
|
||||
.map(|(at, _)| at)
|
||||
.find(|at| starts(*at) && doc.get(at + key.len()..).is_some_and(|rest| rest.starts_with(':') || rest.starts_with("?:")))
|
||||
.unwrap_or(usize::MAX)
|
||||
}
|
||||
|
||||
/// A slider of a colour adjustment dialog: (effect, field, label, range, rail colour at 0..1).
|
||||
type Slider = (&'static str, &'static str, &'static str, (f64, f64), fn(f32) -> egui::Color32);
|
||||
|
||||
|
||||
@@ -197,19 +197,22 @@ pub(super) type Choices = &'static [(&'static str, &'static str)];
|
||||
|
||||
/// Generic editor for command/effect parameters: numbers, booleans, strings and colours; the
|
||||
/// parameters `is_length` names are distances shown and typed in `unit`, those `choices` gives
|
||||
/// choices for are dropdowns. Returns true when a value changed.
|
||||
/// choices for are dropdowns. Fields come in `rank` order (by name among equals). Returns true when
|
||||
/// a value changed.
|
||||
pub(super) fn param_fields(
|
||||
ui: &mut egui::Ui,
|
||||
d: &mut Dialog,
|
||||
is_length: &dyn Fn(&str) -> bool,
|
||||
choices: &dyn Fn(&str) -> Option<Choices>,
|
||||
rank: &dyn Fn(&str) -> usize,
|
||||
unit: Unit,
|
||||
) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let mut changed = false;
|
||||
egui::Grid::new("fxgrid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
|
||||
let keys: Vec<(String, Value)> =
|
||||
let mut keys: Vec<(String, Value)> =
|
||||
d.fields.iter().filter(|(k, _)| !k.starts_with("__") && k.as_str() != "preview").map(|(k, v)| (k.clone(), v.clone())).collect();
|
||||
keys.sort_by_key(|(k, _)| rank(k));
|
||||
for (k, v) in keys {
|
||||
ui.label(egui::RichText::new(humanized(&k)).color(t.text));
|
||||
if let Some(cur) = crate::widgets::blend_param(&k, &v) {
|
||||
|
||||
@@ -114,11 +114,17 @@ pub(super) fn show_preview(ui: &mut egui::Ui, tex: Option<&egui::TextureHandle>,
|
||||
|
||||
/// Open `bytes` (or place them with the `file.place` params `place`) through the dialog when the
|
||||
/// file is a PDF (or `.ai`/`.ait`) with several pages or a password; false when there's nothing to
|
||||
/// ask (the caller goes on).
|
||||
/// ask (the caller goes on), including a file that carries its VectorCraft document when opening.
|
||||
pub fn offer(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option<String>, place: Option<&Value>) -> bool {
|
||||
if !fileio::detect(name, bytes).is_some_and(|f| matches!(f.id, "pdf" | "ai" | "ait")) {
|
||||
return false;
|
||||
}
|
||||
// A file carrying its VectorCraft document (a saved .ai, or a PDF saved with Preserve Editing)
|
||||
// opens as that document, every artboard and layer: there are no pages to pick. Placing still
|
||||
// asks, since a place takes one page.
|
||||
if place.is_none() && vectorcraft_pdf::editing_with(bytes, None).is_some() {
|
||||
return false;
|
||||
}
|
||||
let (pages, locked) = match vectorcraft_pdf::info(bytes, None) {
|
||||
Ok(i) if i.pages.len() > 1 => (i.pages.len(), false),
|
||||
Err(PdfError::NeedsPassword) => (0, true),
|
||||
|
||||
@@ -166,6 +166,30 @@ fn effect_preview_is_kept_as_one_undo_step() {
|
||||
assert_eq!(app.session.doc().unwrap().doc, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raster_filter_dialogs_preview_and_apply() {
|
||||
for (effect, key, value) in [("blur.radial", "method", "zoom"), ("blur.smart", "quality", "high"), ("sharpen.unsharpMask", "amount", "120")] {
|
||||
let mut app = app();
|
||||
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
app.run("effect.dialog", json!({"effect": effect})).unwrap();
|
||||
let d = app.ui.dialog.as_mut().unwrap();
|
||||
let v: serde_json::Value = value.parse::<f64>().map_or_else(|_| json!(value), |n| json!(n));
|
||||
d.fields.insert(key.into(), v.clone());
|
||||
frame(&mut app, Default::default());
|
||||
assert!(app.session.in_interaction(), "{effect}: previews");
|
||||
confirm(&mut app).unwrap();
|
||||
let n = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().clone();
|
||||
assert_eq!(n.appearance.effects.len(), 1, "{effect}");
|
||||
assert_eq!(n.appearance.effects[0].params[key], v, "{effect}");
|
||||
}
|
||||
// Fields follow the effect's documented order (its dialog's), not the alphabet.
|
||||
let doc = vectorcraft_effects::effect_info("blur.smart").unwrap().params;
|
||||
let ranks: Vec<usize> = ["radius", "threshold", "quality"].iter().map(|k| super::effect::doc_rank(doc, k)).collect();
|
||||
assert!(ranks.windows(2).all(|w| w[0] < w[1]), "{ranks:?}");
|
||||
assert_eq!(super::effect::doc_rank(doc, "nope"), usize::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_dialog_applies_to_its_appearance_item() {
|
||||
let mut app = app();
|
||||
|
||||
@@ -165,3 +165,35 @@ fn placing_a_pdf_page_asks_for_the_page_and_crop_box() {
|
||||
assert!(app.ui.dialog.is_none());
|
||||
assert_eq!(placed(&app).1.width(), 80.0);
|
||||
}
|
||||
|
||||
/// A saved `.ai` with several artboards carries its VectorCraft document: opening it restores that
|
||||
/// document (artboards, layers) instead of asking which pages to import.
|
||||
#[test]
|
||||
fn opening_a_saved_ai_with_several_artboards_restores_it_without_asking() {
|
||||
let mut app = app();
|
||||
app.run("artboard.new", json!({"width": 200, "height": 100, "name": "Second"})).unwrap();
|
||||
app.run("layer.new", json!({"name": "Front"})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap();
|
||||
let saved = app.run("document.save", json!({"format": "ai"})).unwrap();
|
||||
let bytes = vectorcraft_format::base64_decode(saved["dataBase64"].as_str().unwrap()).unwrap();
|
||||
assert!(vectorcraft_pdf::info(&bytes, None).unwrap().pages.len() > 1, "a page per artboard");
|
||||
|
||||
io::open_bytes(&mut app, "two.ai", &bytes, Some("/tmp/two.ai".into())).unwrap();
|
||||
assert!(app.ui.dialog.is_none(), "no Import PDF dialog");
|
||||
assert_eq!(app.session.documents().len(), 2);
|
||||
let doc = &app.session.active().unwrap().doc;
|
||||
assert_eq!(doc.artboards.len(), 2);
|
||||
assert_eq!(doc.artboards[1].name, "Second");
|
||||
assert!(doc.layers.iter().any(|l| l.name.as_deref() == Some("Front")), "layers come back");
|
||||
}
|
||||
|
||||
/// A PDF without a VectorCraft document still asks, and placing a saved `.ai` still asks for the page.
|
||||
#[test]
|
||||
fn placing_a_saved_ai_still_asks_for_the_page() {
|
||||
let mut app = app();
|
||||
app.run("artboard.new", json!({"width": 200, "height": 100})).unwrap();
|
||||
let saved = app.run("document.save", json!({"format": "ai"})).unwrap();
|
||||
let bytes = vectorcraft_format::base64_decode(saved["dataBase64"].as_str().unwrap()).unwrap();
|
||||
assert!(import_pdf::offer(&mut app, "two.ai", &bytes, None, Some(&json!({}))));
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().str("mode"), "place");
|
||||
}
|
||||
@@ -643,3 +643,9 @@
|
||||
Constrain Omezit
|
||||
Free Transform Volná transformace
|
||||
Perspective Distort Perspektivní deformace
|
||||
|
||||
# Photoshop-style raster effects (Effect menu, their dialogs).
|
||||
Radial Blur… Radiální rozostření…
|
||||
Smart Blur… Chytré rozostření…
|
||||
Sharpen Zostření
|
||||
Unsharp Mask… Maska rozostření…
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -11,6 +11,23 @@
|
||||
# Coverage: the whole interface — every menu string and every `tl!` literal (`complete_menus`,
|
||||
# tested). Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language
|
||||
# names, OpenType, the built-in workspace names and the bracketed perspective grid preset names.
|
||||
# Photoshop-style raster effects (Effect menu, their dialogs).
|
||||
Radial Blur… Desenfoque radial…
|
||||
Smart Blur… Desenfoque inteligente…
|
||||
Sharpen Enfocar
|
||||
Unsharp Mask… Máscara de enfoque…
|
||||
Radial Blur Desenfoque radial
|
||||
Smart Blur Desenfoque inteligente
|
||||
Unsharp Mask Máscara de enfoque
|
||||
Gaussian Blur Desenfoque gaussiano
|
||||
Method Método
|
||||
Quality Calidad
|
||||
Radius Radio
|
||||
Spin Giro
|
||||
Draft Borrador
|
||||
Good Buena
|
||||
Best Óptima
|
||||
|
||||
# Status and error messages too (`@msg` rows at the end, `COMPLETE_MESSAGES` in the tests).
|
||||
# A native speaker's review is welcome: keep the placeholders ({name}), the trailing "…" and the
|
||||
# leading/trailing spaces of fragments exactly as they are.
|
||||
@@ -203,6 +220,7 @@
|
||||
Auto Size New Area Type Ajustar automáticamente el tamaño del nuevo texto de área
|
||||
Auto Spell Check Revisión ortográfica automática
|
||||
Auto-Collapse Iconic Panels Contraer automáticamente los paneles de iconos
|
||||
Show Tool Group Labels Mostrar etiquetas de grupos de herramientas
|
||||
Auto-Rotate (turn the paper to each artboard) Rotar automáticamente (girar el papel según cada mesa de trabajo)
|
||||
Automatic flatness Lisura automática
|
||||
Automatically Automáticamente
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -12,6 +12,23 @@
|
||||
# Coverage: the whole interface — every menu string and every `tl!` literal (`complete_menus`,
|
||||
# tested). Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language
|
||||
# names, OpenType, the built-in workspace names and the bracketed perspective grid preset names.
|
||||
# Photoshop-style raster effects (Effect menu, their dialogs).
|
||||
Radial Blur… Flou radial…
|
||||
Smart Blur… Flou intelligent…
|
||||
Sharpen Netteté
|
||||
Unsharp Mask… Masque de netteté…
|
||||
Radial Blur Flou radial
|
||||
Smart Blur Flou intelligent
|
||||
Unsharp Mask Masque de netteté
|
||||
Gaussian Blur Flou gaussien
|
||||
Method Méthode
|
||||
Quality Qualité
|
||||
Radius Rayon
|
||||
Spin Rotation
|
||||
Draft Brouillon
|
||||
Good Bonne
|
||||
Best Optimale
|
||||
|
||||
# Status and error messages too (`@msg` rows at the end, `COMPLETE_MESSAGES` in the tests).
|
||||
# A native speaker's review is welcome: keep the placeholders ({name}), the trailing "…" and the
|
||||
# leading/trailing spaces of fragments exactly as they are.
|
||||
@@ -204,6 +221,7 @@
|
||||
Auto Size New Area Type Taille automatique du nouveau texte captif
|
||||
Auto Spell Check Vérification orthographique automatique
|
||||
Auto-Collapse Iconic Panels Réduire automatiquement les panneaux en icônes
|
||||
Show Tool Group Labels Afficher les libellés des groupes d'outils
|
||||
Auto-Rotate (turn the paper to each artboard) Rotation automatique (orienter le papier selon chaque plan de travail)
|
||||
Automatic flatness Planéité automatique
|
||||
Automatically Automatiquement
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -11,6 +11,23 @@
|
||||
# Coverage: the whole interface — every menu string and every `tl!` literal (`complete_menus`,
|
||||
# tested). Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language
|
||||
# names, OpenType, the built-in workspace names and the bracketed perspective grid preset names.
|
||||
# Photoshop-style raster effects (Effect menu, their dialogs).
|
||||
Radial Blur… Sfocatura radiale…
|
||||
Smart Blur… Sfocatura intelligente…
|
||||
Sharpen Nitidezza
|
||||
Unsharp Mask… Maschera di contrasto…
|
||||
Radial Blur Sfocatura radiale
|
||||
Smart Blur Sfocatura intelligente
|
||||
Unsharp Mask Maschera di contrasto
|
||||
Gaussian Blur Sfocatura gaussiana
|
||||
Method Metodo
|
||||
Quality Qualità
|
||||
Radius Raggio
|
||||
Spin Rotazione
|
||||
Draft Bozza
|
||||
Good Buona
|
||||
Best Ottima
|
||||
|
||||
# Status and error messages too (`@msg` rows at the end, `COMPLETE_MESSAGES` in the tests).
|
||||
# A native speaker's review is welcome: keep the placeholders ({name}), the trailing "…" and the
|
||||
# leading/trailing spaces of fragments exactly as they are.
|
||||
@@ -203,6 +220,7 @@
|
||||
Auto Size New Area Type Dimensionamento automatico del nuovo testo in area
|
||||
Auto Spell Check Controllo ortografico automatico
|
||||
Auto-Collapse Iconic Panels Comprimi automaticamente i pannelli a icona
|
||||
Show Tool Group Labels Mostra etichette dei gruppi di strumenti
|
||||
Auto-Rotate (turn the paper to each artboard) Rotazione automatica (orienta la carta in base a ogni tavola da disegno)
|
||||
Automatic flatness Planarità automatica
|
||||
Automatically Automaticamente
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -810,6 +810,7 @@
|
||||
Auto 自動
|
||||
Auto Size New Area Type 新規エリア内文字を自動サイズ調整
|
||||
Auto-Collapse Iconic Panels アイコンパネルを自動的に折りたたむ
|
||||
Show Tool Group Labels ツールグループのラベルを表示
|
||||
Auto-Rotate (turn the paper to each artboard) 自動回転 (アートボードごとに用紙を回転)
|
||||
Automatic flatness フラットネスを自動設定
|
||||
Automatically 自動
|
||||
@@ -3104,3 +3105,20 @@ theme Appearance 外観
|
||||
Constrain 制限
|
||||
Free Transform 自由変形
|
||||
Perspective Distort 遠近変形
|
||||
|
||||
# Photoshop-style raster effects (Effect menu, their dialogs).
|
||||
Radial Blur… ぼかし (放射状)…
|
||||
Smart Blur… ぼかし (詳細)…
|
||||
Sharpen シャープ
|
||||
Unsharp Mask… アンシャープマスク…
|
||||
Radial Blur ぼかし (放射状)
|
||||
Smart Blur ぼかし (詳細)
|
||||
Unsharp Mask アンシャープマスク
|
||||
Gaussian Blur ぼかし (ガウス)
|
||||
Method 方法
|
||||
Quality 画質
|
||||
Radius 半径
|
||||
Spin 回転
|
||||
Draft ドラフト
|
||||
Good 標準
|
||||
Best 高品質
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -252,6 +252,7 @@
|
||||
Auto Size New Area Type Dimensionar Automaticamente Novo Texto de Área
|
||||
Auto Spell Check Verificação Ortográfica Automática
|
||||
Auto-Collapse Iconic Panels Recolher Automaticamente Painéis Icônicos
|
||||
Show Tool Group Labels Mostrar Rótulos dos Grupos de Ferramentas
|
||||
Auto-Rotate (turn the paper to each artboard) Girar Automaticamente (gire o papel para cada mesa de trabalho)
|
||||
Automatic Automático
|
||||
Automatic flatness Achatamento automático
|
||||
@@ -2675,3 +2676,9 @@
|
||||
Constrain Restringir
|
||||
Free Transform Transformação Livre
|
||||
Perspective Distort Distorção de Perspectiva
|
||||
|
||||
# Photoshop-style raster effects (Effect menu, their dialogs).
|
||||
Radial Blur… Desfoque Radial…
|
||||
Smart Blur… Desfoque Inteligente…
|
||||
Sharpen Nitidez
|
||||
Unsharp Mask… Máscara de Nitidez…
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -9,6 +9,23 @@
|
||||
# Coverage: the whole interface — every menu string and every `tl!` literal (`complete_menus`,
|
||||
# tested). Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language
|
||||
# names, OpenType, the built-in workspace names and the bracketed perspective grid preset names.
|
||||
# Photoshop-style raster effects (Effect menu, their dialogs).
|
||||
Radial Blur… Радиальное размытие…
|
||||
Smart Blur… Умное размытие…
|
||||
Sharpen Резкость
|
||||
Unsharp Mask… Нерезкая маска…
|
||||
Radial Blur Радиальное размытие
|
||||
Smart Blur Умное размытие
|
||||
Unsharp Mask Нерезкая маска
|
||||
Gaussian Blur Размытие по Гауссу
|
||||
Method Метод
|
||||
Quality Качество
|
||||
Radius Радиус
|
||||
Spin Вращение
|
||||
Draft Черновое
|
||||
Good Хорошее
|
||||
Best Наилучшее
|
||||
|
||||
# Status and error messages too (`@msg` rows at the end, `COMPLETE_MESSAGES` in the tests).
|
||||
# A native speaker's review is welcome: keep the placeholders ({name}), the trailing "…" and the
|
||||
# leading/trailing spaces of fragments exactly as they are.
|
||||
@@ -201,6 +218,7 @@
|
||||
Auto Size New Area Type Автоматически подгонять размер нового текста в области
|
||||
Auto Spell Check Автоматическая проверка орфографии
|
||||
Auto-Collapse Iconic Panels Автоматически сворачивать значки панелей
|
||||
Show Tool Group Labels Показывать названия групп инструментов
|
||||
Auto-Rotate (turn the paper to each artboard) Автоповорот (поворачивать лист по каждой монтажной области)
|
||||
Automatic flatness Автоматическая плоскостность
|
||||
Automatically Автоматически
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -203,6 +203,7 @@
|
||||
Auto Size New Area Type 自动调整新区域文字大小
|
||||
Auto Spell Check 自动拼写检查
|
||||
Auto-Collapse Iconic Panels 自动折叠图标面板
|
||||
Show Tool Group Labels 显示工具组标签
|
||||
Auto-Rotate (turn the paper to each artboard) 自动旋转 (为每个画板转动纸张)
|
||||
Automatic flatness 自动平滑度
|
||||
Automatically 自动
|
||||
@@ -3356,3 +3357,20 @@ axis Both 两者
|
||||
Select Shaper Face 选择 Shaper 区域
|
||||
Constrain 约束
|
||||
Perspective Distort 透视扭曲
|
||||
|
||||
# Photoshop-style raster effects (Effect menu, their dialogs).
|
||||
Radial Blur… 径向模糊…
|
||||
Smart Blur… 智能模糊…
|
||||
Sharpen 锐化
|
||||
Unsharp Mask… 反锐化遮罩…
|
||||
Radial Blur 径向模糊
|
||||
Smart Blur 智能模糊
|
||||
Unsharp Mask 反锐化遮罩
|
||||
Gaussian Blur 高斯模糊
|
||||
Method 方法
|
||||
Quality 质量
|
||||
Radius 半径
|
||||
Spin 旋转
|
||||
Draft 草稿
|
||||
Good 良好
|
||||
Best 最佳
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -214,6 +214,7 @@
|
||||
Auto Size New Area Type 自動調整新區域文字大小
|
||||
Auto Spell Check 自動拼字檢查
|
||||
Auto-Collapse Iconic Panels 自動收合圖示面板
|
||||
Show Tool Group Labels 顯示工具組標籤
|
||||
Auto-Rotate (turn the paper to each artboard) 自動旋轉 (將紙張轉向各工作區域)
|
||||
Automatic flatness 自動平滑度
|
||||
Automatically 自動
|
||||
@@ -3134,3 +3135,20 @@ axis Both 兩者
|
||||
Constrain 強制
|
||||
Free Transform 任意變形
|
||||
Perspective Distort 透視扭曲
|
||||
|
||||
# Photoshop-style raster effects (Effect menu, their dialogs).
|
||||
Radial Blur… 放射狀模糊…
|
||||
Smart Blur… 智慧型模糊…
|
||||
Sharpen 銳利化
|
||||
Unsharp Mask… 反銳利化遮罩…
|
||||
Radial Blur 放射狀模糊
|
||||
Smart Blur 智慧型模糊
|
||||
Unsharp Mask 反銳利化遮罩
|
||||
Gaussian Blur 高斯模糊
|
||||
Method 方法
|
||||
Quality 品質
|
||||
Radius 半徑
|
||||
Spin 旋轉
|
||||
Draft 草稿
|
||||
Good 良好
|
||||
Best 最佳
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -534,6 +534,14 @@ impl VectorcraftApp {
|
||||
{
|
||||
p.insert("center".into(), serde_json::json!([c.x, c.y]));
|
||||
}
|
||||
// In place, in front, in back: onto the active artboard (#693).
|
||||
if matches!(id, "edit.pasteInPlace" | "edit.pasteInFront" | "edit.pasteInBack")
|
||||
&& params.get("artboard").is_none()
|
||||
&& let Some(i) = self.view().map(|v| v.artboard)
|
||||
&& let Some(p) = params.as_object_mut()
|
||||
{
|
||||
p.insert("artboard".into(), serde_json::json!(i));
|
||||
}
|
||||
if let Some(r) = dialogs::swatch_conflict::ask(self, id, ¶ms) {
|
||||
return r;
|
||||
}
|
||||
|
||||
+32
-19
@@ -3223,6 +3223,10 @@ fn font_items() -> Vec<Item> {
|
||||
items.1.clone()
|
||||
}
|
||||
|
||||
/// The raster effects' submenus of the Effect menu (the Photoshop-style effects, below the
|
||||
/// vector effects): (submenu, the catalogue's menu path of its effects).
|
||||
const RASTER_MENUS: [(&str, &[&str]); 2] = [("Blur", &["Effect", "Blur"]), ("Sharpen", &["Effect", "Sharpen"])];
|
||||
|
||||
/// The Effect menu, built from the effects catalogue (vector effects), plus raster effects.
|
||||
fn effect_menu() -> Vec<Item> {
|
||||
let cat = vectorcraft_effects::effect_catalog();
|
||||
@@ -3246,33 +3250,25 @@ fn effect_menu() -> Vec<Item> {
|
||||
"Stylize",
|
||||
"SVG Filters",
|
||||
"Warp",
|
||||
"Blur",
|
||||
];
|
||||
let top_level = |e: &vectorcraft_effects::EffectInfo| e.menu == ["Effect"] && order.contains(&e.label);
|
||||
for sub_name in order {
|
||||
if let Some(e) = cat.iter().find(|e| top_level(e) && e.label == sub_name) {
|
||||
out.push(Item::Cmd(e.label, "effect.apply", json!({ "effect": e.id })));
|
||||
continue;
|
||||
}
|
||||
let items: Vec<Item> = cat
|
||||
.iter()
|
||||
.filter(|e| e.menu.last().copied() == Some(sub_name))
|
||||
// The effects whose catalogue menu path is `path`.
|
||||
let items_at = |path: &[&str]| -> Vec<Item> {
|
||||
cat.iter()
|
||||
.filter(|e| e.menu == path)
|
||||
.map(|e| match e.defaults.as_object().is_some_and(|o| o.is_empty()) {
|
||||
// No options (Effect → Pathfinder): apply directly, like Illustrator.
|
||||
true => Item::Cmd(e.label, "effect.apply", json!({ "effect": e.id })),
|
||||
false => Item::Cmd(e.label, "effect.dialog", json!({ "effect": e.id })),
|
||||
})
|
||||
.collect();
|
||||
if sub_name == "Blur" {
|
||||
// Live-effect plug-ins close the vector effects.
|
||||
out.extend(crate::dialogs::plugin::effect_menu());
|
||||
if !items.is_empty() {
|
||||
out.push(Sep);
|
||||
out.push(Item::Header("Raster Effects"));
|
||||
out.push(sub(sub_name, items));
|
||||
}
|
||||
.collect()
|
||||
};
|
||||
for sub_name in order {
|
||||
if let Some(e) = cat.iter().find(|e| top_level(e) && e.label == sub_name) {
|
||||
out.push(Item::Cmd(e.label, "effect.apply", json!({ "effect": e.id })));
|
||||
continue;
|
||||
}
|
||||
let items = items_at(&["Effect", sub_name]);
|
||||
if items.is_empty() {
|
||||
let placeholder = match sub_name {
|
||||
"3D and Materials" => vec![todo("Extrude & Bevel…"), todo("Revolve…"), todo("Inflate…"), todo("Rotate…"), todo("Materials…")],
|
||||
@@ -3284,8 +3280,25 @@ fn effect_menu() -> Vec<Item> {
|
||||
out.push(sub(sub_name, items));
|
||||
}
|
||||
}
|
||||
// Live-effect plug-ins close the vector effects.
|
||||
out.extend(crate::dialogs::plugin::effect_menu());
|
||||
let raster: Vec<Item> = RASTER_MENUS
|
||||
.iter()
|
||||
.filter_map(|(name, path)| {
|
||||
let items = items_at(path);
|
||||
(!items.is_empty()).then(|| sub(name, items))
|
||||
})
|
||||
.collect();
|
||||
if !raster.is_empty() {
|
||||
out.push(Sep);
|
||||
out.push(Item::Header("Raster Effects"));
|
||||
out.extend(raster);
|
||||
}
|
||||
// Anything not placed above (future effects) still shows up.
|
||||
for e in cat.iter().filter(|e| !top_level(e) && !e.menu.last().is_some_and(|m| order.contains(m))) {
|
||||
let placed = |e: &vectorcraft_effects::EffectInfo| {
|
||||
top_level(e) || order.iter().any(|s| e.menu == ["Effect", *s]) || RASTER_MENUS.iter().any(|(_, path)| e.menu == *path)
|
||||
};
|
||||
for e in cat.iter().filter(|e| !placed(e)) {
|
||||
out.push(Item::Cmd(e.label, "effect.dialog", json!({ "effect": e.id })));
|
||||
}
|
||||
out
|
||||
|
||||
@@ -67,11 +67,21 @@ pub(crate) fn corner_radius(app: &VectorcraftApp, n: &Node) -> Option<f64> {
|
||||
|
||||
/// The Corner Radius field of a live rectangle's or polygon's properties: [`corner_radius`],
|
||||
/// which a new value sets on those corners.
|
||||
/// The width of a panel's number fields: the Transform fields', two to a row beside the reference
|
||||
/// point, their labels and the W/H link, so every field in the panel is as wide (#696). Measured
|
||||
/// from the full row, before its widgets.
|
||||
pub(crate) fn field_width(ui: &Ui) -> f32 {
|
||||
// The reference point and its gap, the four labels and the W/H link.
|
||||
const AROUND: f32 = 37.0 + 70.0;
|
||||
((ui.available_width() - AROUND) / 2.0).clamp(60.0, 110.0)
|
||||
}
|
||||
|
||||
pub(crate) fn corner_radius_row(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, id: &str) {
|
||||
let units = app.session.general_unit();
|
||||
let fw = field_width(ui);
|
||||
ui.horizontal(|ui| {
|
||||
dim_label(ui, tl!("Corner Radius:"));
|
||||
if let Some(r) = crate::widgets::num_field(ui, id, corner_radius(app, n), units, 80.0) {
|
||||
if let Some(r) = crate::widgets::num_field(ui, id, corner_radius(app, n), units, fw) {
|
||||
app.run("object.setLiveShape", json!({"radius": r})).ok();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -207,7 +207,7 @@ fn artboard_sections(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
if let Some(ab) = app.session.active().and_then(|d| d.doc.artboards.get(i)).cloned() {
|
||||
// Its name as it is (names are never translated).
|
||||
ui.label(egui::RichText::new(&ab.name).size(13.0).color(Tokens::get(ui.ctx()).text));
|
||||
let fw = ((ui.available_width() - 50.0) / 2.0).clamp(60.0, 110.0);
|
||||
let fw = super::field_width(ui);
|
||||
egui::Grid::new("ab-grid").num_columns(4).spacing([4.0, 6.0]).min_col_width(0.0).show(ui, |ui| {
|
||||
let r = ab.rect;
|
||||
for (row, [(l1, k1, v1), (l2, k2, v2)]) in
|
||||
@@ -364,9 +364,10 @@ fn document_sections(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
}
|
||||
divider(ui);
|
||||
section_header(ui, tl!("Preferences"));
|
||||
let fw = super::field_width(ui);
|
||||
ui.horizontal(|ui| {
|
||||
dim_label(ui, tl!("Keyboard Increment"));
|
||||
if let Some(v) = widgets::num_field(ui, "kbinc", Some(app.session.prefs.keyboard_increment), units, 80.0) {
|
||||
if let Some(v) = widgets::num_field(ui, "kbinc", Some(app.session.prefs.keyboard_increment), units, fw) {
|
||||
app.session.prefs.keyboard_increment = v.max(0.001);
|
||||
}
|
||||
});
|
||||
@@ -401,14 +402,13 @@ pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let refi: usize = ui.data(|d| d.get_temp(egui::Id::new("refpt"))).unwrap_or(4);
|
||||
let rp = bx.reference_point(refi);
|
||||
section_header(ui, tl!("Transform"));
|
||||
let fw = super::field_width(ui);
|
||||
ui.horizontal(|ui| {
|
||||
if let Some(i) = widgets::reference_point(ui, refi) {
|
||||
ui.data_mut(|d| d.insert_temp(egui::Id::new("refpt"), i));
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
let link = app.session.prefs.constrain_proportions;
|
||||
// Room for the labels and the W/H link.
|
||||
let fw = ((ui.available_width() - 70.0) / 2.0).clamp(60.0, 110.0);
|
||||
egui::Grid::new("xf-grid").num_columns(4).spacing([4.0, 6.0]).min_col_width(0.0).show(ui, |ui| {
|
||||
dim_label(ui, "X:");
|
||||
if let Some(v) = widgets::num_field(ui, "tx", Some(rp.x), units, fw) {
|
||||
@@ -431,11 +431,12 @@ pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
});
|
||||
super::transform::constrain_link(app, ui);
|
||||
});
|
||||
let fw = super::field_width(ui);
|
||||
ui.horizontal(|ui| {
|
||||
let icon = icons::icon(ui, "rotate-ccw", 16.0, Tokens::get(ui.ctx()).icon);
|
||||
crate::scrub::note_label(ui, icon.rect);
|
||||
// The bounding box's angle: a new value turns the selection to it.
|
||||
if let Some(a) = widgets::plain_field(ui, "rot", bx.angle, "°", 2, 70.0) {
|
||||
if let Some(a) = widgets::plain_field(ui, "rot", bx.angle, "°", 2, fw) {
|
||||
app.run("object.rotate", json!({"angle": a, "absolute": true})).ok();
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
@@ -453,9 +454,10 @@ pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
match live {
|
||||
vectorcraft_doc::LiveShape::Rectangle { .. } => corner_radius_row(app, ui, &n, "radius"),
|
||||
vectorcraft_doc::LiveShape::Polygon { sides, .. } => {
|
||||
let fw = super::field_width(ui);
|
||||
ui.horizontal(|ui| {
|
||||
dim_label(ui, tl!("Sides:"));
|
||||
if let Some(s) = widgets::plain_field(ui, "sides", *sides as f64, "", 0, 60.0) {
|
||||
if let Some(s) = widgets::plain_field(ui, "sides", *sides as f64, "", 0, fw) {
|
||||
app.run("object.setLiveShape", json!({"sides": s as u32})).ok();
|
||||
}
|
||||
});
|
||||
@@ -470,10 +472,11 @@ fn appearance_section(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let Some(n) = first_selected(app) else { return };
|
||||
section_header(ui, tl!("Appearance"));
|
||||
fill_stroke_rows(app, ui);
|
||||
let fw = super::field_width(ui);
|
||||
ui.horizontal(|ui| {
|
||||
widgets::field_label(ui, egui::RichText::new(tl!("Opacity")).size(12.0));
|
||||
ui.add_space(8.0);
|
||||
if let Some(o) = widgets::plain_field(ui, "ap-op", n.opacity as f64 * 100.0, "%", 0, 64.0) {
|
||||
if let Some(o) = widgets::plain_field(ui, "ap-op", n.opacity as f64 * 100.0, "%", 0, fw) {
|
||||
app.run("object.setProps", json!({"opacity": o.clamp(0.0, 100.0)})).ok();
|
||||
}
|
||||
if widgets::icon_button(ui, "ellipsis", tl!("Transparency"), false, 22.0).clicked() {
|
||||
@@ -496,6 +499,7 @@ fn appearance_section(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
fn fill_stroke_rows(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let weight = super::stroke::shown_weight(app, super::current_stroke(app).as_ref(), &super::stroke_mixed(app, ui.ctx()));
|
||||
let fw = super::field_width(ui);
|
||||
ui.horizontal(|ui| {
|
||||
super::paint_chip(app, ui, false, 22.0, true);
|
||||
ui.label(egui::RichText::new(tl!("Fill")).size(12.0).color(t.text));
|
||||
@@ -504,7 +508,7 @@ fn fill_stroke_rows(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
super::paint_chip(app, ui, true, 22.0, true);
|
||||
super::stroke::link(app, ui, tl!("Stroke"));
|
||||
ui.add_space(8.0);
|
||||
super::stroke::weight_field(app, ui, "ap-w", weight, 90.0);
|
||||
super::stroke::weight_field(app, ui, "ap-w", weight, fw);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -564,6 +568,34 @@ mod tests {
|
||||
frame(app, ctx, vec![b(false)]);
|
||||
}
|
||||
|
||||
/// #696: the number fields are all as wide: the Transform fields, the rotation, the corner
|
||||
/// radius, Opacity and the stroke weight.
|
||||
#[test]
|
||||
fn the_number_fields_are_all_as_wide() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let texts = frame(&mut app, &ctx, vec![]);
|
||||
// The field each value is shown in: the narrowest widget around its text that isn't the
|
||||
// text alone.
|
||||
let field = |value: &str| {
|
||||
let at = texts.iter().find(|(t, _)| t == value).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("{value}: {texts:?}"));
|
||||
ctx.viewport(|vp| {
|
||||
vp.prev_pass
|
||||
.widgets
|
||||
.layers()
|
||||
.flat_map(|(_, w)| w.iter())
|
||||
.filter(|w| w.rect.contains(at) && w.rect.width() > 30.0 && w.rect.height() < 40.0)
|
||||
.map(|w| w.rect.width())
|
||||
.fold(f32::INFINITY, f32::min)
|
||||
})
|
||||
};
|
||||
let widths = [field("35 pt"), field("0°"), field("0 pt"), field("100%")];
|
||||
assert!(widths.iter().all(|w| (w - widths[0]).abs() < 1.0), "X, rotation, corner radius, opacity: {widths:?}");
|
||||
}
|
||||
|
||||
/// #530: Edit Artboards shows the active artboard, with New Artboard, Delete Artboard and Exit.
|
||||
#[test]
|
||||
fn edit_artboards_adds_artboards_and_exits() {
|
||||
|
||||
@@ -73,6 +73,7 @@ pub(crate) fn all_shortcuts() -> Vec<(KeyboardShortcut, &'static str, serde_json
|
||||
v.push((sc, "window.panel", json!({ "panel": panel })));
|
||||
}
|
||||
}
|
||||
settings_chord(&mut v, cfg!(target_os = "macos"));
|
||||
// Most specific (most modifiers) first so Cmd+Shift+Z isn't eaten by Cmd+Z.
|
||||
v.sort_by_key(|(sc, ..)| {
|
||||
std::cmp::Reverse(sc.modifiers.shift as u8 + sc.modifiers.alt as u8 + sc.modifiers.command as u8 + sc.modifiers.ctrl as u8)
|
||||
@@ -80,6 +81,14 @@ pub(crate) fn all_shortcuts() -> Vec<(KeyboardShortcut, &'static str, serde_json
|
||||
v
|
||||
}
|
||||
|
||||
/// On a Mac, Cmd+, opens Settings as in every Mac app (#663), beside Preferences' own shortcut,
|
||||
/// unless a command or a user's shortcut already took it.
|
||||
fn settings_chord(v: &mut Vec<(KeyboardShortcut, &'static str, serde_json::Value)>, mac: bool) {
|
||||
if let Some(sc) = parse("Cmd+,").filter(|sc| mac && !v.iter().any(|(s, ..)| s == sc)) {
|
||||
v.push((sc, "edit.preferences", json!({})));
|
||||
}
|
||||
}
|
||||
|
||||
/// Consume a press of `sc`. A `=` chord also takes [`Key::Plus`], which is how `+` arrives from
|
||||
/// the numpad and from layouts where it has its own key (Shift+`=` arrives as either; extra Shift
|
||||
/// is ignored).
|
||||
@@ -687,6 +696,24 @@ mod tests {
|
||||
assert_eq!(digit_of(Key::A), None);
|
||||
}
|
||||
|
||||
/// Cmd+, opens Settings on a Mac only, and never takes a chord a command already has.
|
||||
#[test]
|
||||
fn cmd_comma_opens_settings_on_a_mac() {
|
||||
let comma = parse("Cmd+,").unwrap();
|
||||
let mut v = vec![];
|
||||
super::settings_chord(&mut v, false);
|
||||
assert!(v.is_empty(), "not elsewhere");
|
||||
super::settings_chord(&mut v, true);
|
||||
assert_eq!(v.len(), 1);
|
||||
assert_eq!((v[0].0, v[0].1), (comma, "edit.preferences"));
|
||||
let mut taken = vec![(comma, "view.zoomIn", json!({}))];
|
||||
super::settings_chord(&mut taken, true);
|
||||
assert_eq!(taken.len(), 1, "a command that has it keeps it");
|
||||
if cfg!(target_os = "macos") {
|
||||
assert!(super::all_shortcuts().iter().any(|(sc, id, _)| *sc == comma && *id == "edit.preferences"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses() {
|
||||
let s = parse("Cmd+Shift+]").unwrap();
|
||||
|
||||
@@ -169,7 +169,11 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, cols: usize, floating: Option<egu
|
||||
let mut raise: Option<&'static str> = None;
|
||||
let mut i = 0;
|
||||
while i < all.len() {
|
||||
if let Some(cat) = all[i].0 {
|
||||
if all[i].0.is_some() && !app.session.prefs.tool_group_labels {
|
||||
// User Interface › Show Tool Group Labels off: a faint dash between the groups.
|
||||
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 9.0), Sense::hover());
|
||||
ui.painter().line_segment([r.center() - vec2(6.0, 0.0), r.center() + vec2(6.0, 0.0)], egui::Stroke::new(1.0, t.divider));
|
||||
} else if let Some(cat) = all[i].0 {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 18.0), Sense::hover());
|
||||
// A long name is cut to its first four characters in the single column.
|
||||
let cat = tl!(cat);
|
||||
@@ -1205,6 +1209,35 @@ pub(crate) mod tests {
|
||||
assert!(r.min.x.is_finite() && r.min.y.is_finite() && r.left() >= 0.0, "on screen: {r:?}");
|
||||
}
|
||||
|
||||
/// User Interface › Show Tool Group Labels (#663): on, the toolbar names its groups; off, it
|
||||
/// shows none of the names, a faint dash between the groups instead.
|
||||
#[test]
|
||||
fn tool_group_labels_can_be_hidden() {
|
||||
fn texts(s: &egui::Shape, out: &mut Vec<String>) {
|
||||
match s {
|
||||
egui::Shape::Text(t) => out.push(t.galley.text().to_string()),
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let draw = |app: &mut VectorcraftApp| {
|
||||
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 1600.0))), ..Default::default() };
|
||||
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
|
||||
out.textures_delta.clear();
|
||||
let mut shown = vec![];
|
||||
out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown));
|
||||
shown
|
||||
};
|
||||
let with = draw(&mut app);
|
||||
assert!(with.iter().any(|t| t == "Shapes") && with.iter().any(|t| t == "Draw"), "{with:?}");
|
||||
app.run("prefs.set", json!({"key": "toolGroupLabels", "value": false})).unwrap();
|
||||
let without = draw(&mut app);
|
||||
assert!(!without.iter().any(|t| ["Select", "Shapes", "Draw", "Modify", "Type"].contains(&t.as_str())), "{without:?}");
|
||||
}
|
||||
|
||||
/// Hover `at` for two seconds, coming from elsewhere: the texts painted meanwhile.
|
||||
fn hovered_texts(app: &mut VectorcraftApp, ctx: &egui::Context, t0: f64, at: Pos2) -> Vec<String> {
|
||||
fn texts(s: &egui::Shape, out: &mut Vec<String>) {
|
||||
|
||||
@@ -88,6 +88,12 @@
|
||||
<key>CFBundleTypeRole</key><string>Editor</string>
|
||||
<key>LSHandlerRank</key><string>Alternate</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key><string>Affinity</string>
|
||||
<key>CFBundleTypeExtensions</key><array><string>af</string><string>afdesign</string><string>afpub</string></array>
|
||||
<key>CFBundleTypeRole</key><string>Viewer</string>
|
||||
<key>LSHandlerRank</key><string>Alternate</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key><string>DXF</string>
|
||||
<key>CFBundleTypeExtensions</key><array><string>dxf</string></array>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
c499cf3ca73cf7cd3e2e65bed048d1b8ecf03e7167b74d54f0ae6db31868cab8 affinity3-mexican-guy.af
|
||||
730e3fae51d9044ada58f51c7b5f9327d99e8d62376453bc251d0d26cda47743 affinity3-mexican-man.af
|
||||
5f5fccc0bd8be2bb3b2a07461ec77a012a61eef1883485d7c80f72d4383231ce affinity3-mexican-woman.af
|
||||
0a52bb923c949c566a4339b996bce1a67b683f61def1a73dfefb910effaa450d affinity3-playing-cards.af
|
||||
87a7bd93de16c404f6c6dd9cdebff96ec47832a3b342d2ce0af73d9a87e209e0 designer-beer.afdesign
|
||||
09ba790c428e8e09dc0b9ddebb4f3db6daefaa2af1da492ee34e73fba1b8af69 designer-cactus.afdesign
|
||||
c3bcd0395b407493bc9d2877d424029b70f0e4c817a56e893f7ac74973df52e5 designer-car.afdesign
|
||||
112562bc91e7cccf003d34364430544208373c4fb4bb4ab9acdaf27cf0125047 designer-flowers.afdesign
|
||||
023531b8216f2d4da24ab715e3b313506712e50d5f5bf7da927f434def1d0ea2 designer-lion-track.afdesign
|
||||
cc8ca0db0d8fc545eb29153be900f153fa6b7b7eac068d05e54a502aba1264fe afdesignload-color.afdesign
|
||||
9a17f500d09fb3b1a70d24aabdc2882955ec3eac3700a474549e6100be3a8484 afdesignload-layer_mode.afdesign
|
||||
77ae60c02749224fe2c52691c951568138eaed5f8584ebc5da83715fae0234dd afdesignload-layer_test.afdesign
|
||||
04944918ff8e29c5c1b466b7e43c232f7ab80db15fb934080bbcd7baaf093e22 afdesignload-margins.afdesign
|
||||
35afa31b89f2b9970eb3701ee64e2a19476383637e8b07689334d6ffe556b593 afdesignload-raster_test.afdesign
|
||||
e56c35f876277b101790ff4384cfde103a093ed6c81790a2d62176de21da339f afdesignload-revision_test.afdesign
|
||||
08946c60c0da1c8aeea92d65acfb244721eeb3b480ed358609f4a48bbecfc5b9 afdesignload-shape_test.afdesign
|
||||
7306ab4fcbdc225d816f32a0d2aca15ac887053f60170459ac947e70f4ef343d afdesignload-slice_test.afdesign
|
||||
e5d04bac0235af9386aea251cdd6903ed7810d335fdb6194d0483ea30b14e24a afdesignload-test_path.afdesign
|
||||
fbb196e918e302c16f8de9f2e6dffaef70ab3c317b32a9f44607fd4b2ead0206 jac21-SimpleLogo.afdesign
|
||||
9d874245936483de3ec04cca48b383028d33666801dff1814246a44e695e29b8 jac21-SimpleLogoBanner.afdesign
|
||||
e1b121d7c3d02356baa5dea512f5aa1fe77249c28d7b227e29cab2bc67ec323a eviltwo-AssetStore.aftemplate
|
||||
ec1072cb03fcd0ed7ac1dfc7e087f4920df545fe9c84a386a7dac2fd33fbbe1f leakcanary-vector_icon.afdesign
|
||||
@@ -0,0 +1,110 @@
|
||||
//! `cargo xtask corpus --affinity`: public Affinity documents saved by Affinity 1.x to 3.x, from
|
||||
//! their upstream repositories at pinned commits, each checked against `xtask/affinity-corpus.sha256`.
|
||||
//! 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.
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use crate::{root, run, sha256};
|
||||
|
||||
/// (repository, commit, licence, [(path in the repository, file name in corpus/affinity)]).
|
||||
type Source = (&'static str, &'static str, &'static str, &'static [(&'static str, &'static str)]);
|
||||
|
||||
const SOURCES: &[Source] = &[
|
||||
(
|
||||
"samuel-etver/vector-art",
|
||||
"255f8add3c8f0740196e22bd59502b811b532f0b",
|
||||
"CC0-1.0",
|
||||
&[
|
||||
("simple/mexican-guy.af", "affinity3-mexican-guy.af"),
|
||||
("simple/mexican-man.af", "affinity3-mexican-man.af"),
|
||||
("simple/mexican-woman.af", "affinity3-mexican-woman.af"),
|
||||
("simple/playing-cards.af", "affinity3-playing-cards.af"),
|
||||
("simple/beer.afdesign", "designer-beer.afdesign"),
|
||||
("simple/cactus.afdesign", "designer-cactus.afdesign"),
|
||||
("simple/car.afdesign", "designer-car.afdesign"),
|
||||
("simple/flowers.afdesign", "designer-flowers.afdesign"),
|
||||
("simple/lion-track.afdesign", "designer-lion-track.afdesign"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"NickBeeuwsaert/AFDesignLoad",
|
||||
"a18dd50a7079fb861a28835eeefa0d015f03f4a1",
|
||||
"MIT",
|
||||
&[
|
||||
("testDesigns/color.afdesign", "afdesignload-color.afdesign"),
|
||||
("testDesigns/layer_mode.afdesign", "afdesignload-layer_mode.afdesign"),
|
||||
("testDesigns/layer_test.afdesign", "afdesignload-layer_test.afdesign"),
|
||||
("testDesigns/margins.afdesign", "afdesignload-margins.afdesign"),
|
||||
("testDesigns/raster_test.afdesign", "afdesignload-raster_test.afdesign"),
|
||||
("testDesigns/revision_test.afdesign", "afdesignload-revision_test.afdesign"),
|
||||
("testDesigns/shape_test.afdesign", "afdesignload-shape_test.afdesign"),
|
||||
("testDesigns/slice_test.afdesign", "afdesignload-slice_test.afdesign"),
|
||||
("testDesigns/test_path.afdesign", "afdesignload-test_path.afdesign"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Jac21/Branding",
|
||||
"57ae3f45bf4637f6927c0b07de25b922c55f944f",
|
||||
"MIT",
|
||||
&[
|
||||
("Logos/JC/DesignerFiles/Affinity/SimpleLogo.afdesign", "jac21-SimpleLogo.afdesign"),
|
||||
("Logos/JC/DesignerFiles/Affinity/SimpleLogoBanner.afdesign", "jac21-SimpleLogoBanner.afdesign"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"eviltwo/AssetStoreTemplate",
|
||||
"f671981ee89d15526285b3cc87b4393d3defe644",
|
||||
"MIT",
|
||||
&[("AssetStoreTemplate/AssetStore.aftemplate", "eviltwo-AssetStore.aftemplate")],
|
||||
),
|
||||
(
|
||||
"square/leakcanary",
|
||||
"00880a8fb66eb4046a0124bff8303a04874bc07e",
|
||||
"Apache-2.0",
|
||||
&[("docs/assets/vector_icon.afdesign", "leakcanary-vector_icon.afdesign")],
|
||||
),
|
||||
];
|
||||
|
||||
const MANIFEST: &str = "xtask/affinity-corpus.sha256";
|
||||
|
||||
pub fn fetch() -> Result<(), String> {
|
||||
let root = root();
|
||||
let manifest = std::fs::read_to_string(root.join(MANIFEST)).map_err(|e| format!("{MANIFEST}: {e}"))?;
|
||||
let expected: Vec<(&str, &str)> = manifest.lines().filter_map(|l| l.split_once(" ")).collect();
|
||||
let dest = root.join("corpus").join("affinity");
|
||||
std::fs::create_dir_all(&dest).map_err(|e| format!("create {}: {e}", dest.display()))?;
|
||||
let mut sources = String::from(
|
||||
"# Public Affinity documents\n\nFetched by `cargo xtask corpus --affinity` and checked against `xtask/affinity-corpus.sha256`.\nGitignored; never commit these files.\n\n| File | Source | Licence |\n|---|---|---|\n",
|
||||
);
|
||||
let mut count = 0;
|
||||
for (repo, commit, licence, files) in SOURCES {
|
||||
for (path, name) in *files {
|
||||
let sha = expected.iter().find(|(_, n)| n == name).map(|(s, _)| *s).ok_or_else(|| format!("{name} is missing from {MANIFEST}"))?;
|
||||
let out = dest.join(name);
|
||||
if !verified(&out, sha) {
|
||||
let url = format!("https://raw.githubusercontent.com/{repo}/{commit}/{path}");
|
||||
let mut curl = Command::new("curl");
|
||||
curl.args(["-fsSL", "--retry", "3", "-A", "VectorCraft-dev", "-o"]).arg(&out).arg(&url);
|
||||
run(curl, &format!("curl {url}"))?;
|
||||
if !verified(&out, sha) {
|
||||
let _ = std::fs::remove_file(&out);
|
||||
return Err(format!("{name}: sha256 does not match {MANIFEST}"));
|
||||
}
|
||||
}
|
||||
sources.push_str(&format!("| {name} | https://github.com/{repo}/blob/{commit}/{path} | {licence} |\n"));
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
std::fs::write(dest.join("SOURCES.md"), sources).map_err(|e| format!("SOURCES.md: {e}"))?;
|
||||
println!("Affinity: {count} documents verified in {}", dest.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn verified(path: &Path, sha: &str) -> bool {
|
||||
std::fs::read(path).is_ok_and(|b| sha256::hex(&b) == sha)
|
||||
}
|
||||
@@ -60,6 +60,7 @@ pub const TABLE: &[(&str, Class)] = &[
|
||||
("eps", Class::Layer(3)),
|
||||
("metafile", Class::Layer(3)),
|
||||
("plugins", Class::Layer(2)),
|
||||
("affinity", Class::Standalone),
|
||||
];
|
||||
|
||||
/// Explicit orderings *within* a layer (earlier may be used by later).
|
||||
|
||||
+8
-2
@@ -3,11 +3,13 @@
|
||||
//! Pure Rust (std + serde_json). External tools (`cargo`, `curl`, `tar`) are
|
||||
//! invoked through `std::process::Command`.
|
||||
|
||||
mod affinity_corpus;
|
||||
mod assets;
|
||||
mod brands;
|
||||
mod bundle;
|
||||
mod ico;
|
||||
mod layers;
|
||||
mod sha256;
|
||||
mod stats;
|
||||
mod version;
|
||||
|
||||
@@ -23,8 +25,9 @@ commands:
|
||||
layers enforce the crate dependency layering (plan/architecture.md §3)
|
||||
wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates
|
||||
ci fmt --check, clippy -D warnings, test, assets, brands, layers, wasm (stops at first failure)
|
||||
corpus [--download]
|
||||
show where test corpora live; --download fetches PngSuite into corpus/pngsuite
|
||||
corpus [--download] [--affinity]
|
||||
show where test corpora live; --download fetches PngSuite into corpus/pngsuite,
|
||||
--affinity the pinned public Affinity documents into corpus/affinity
|
||||
bundle build dist/VectorCraft.app (macOS) with assets/app-icon/vectorcraft.icns
|
||||
ico <out.ico> <png>...
|
||||
pack PNGs into a Windows .ico (used by packaging/icons.sh)
|
||||
@@ -40,6 +43,7 @@ fn main() -> ExitCode {
|
||||
Some("layers") => cmd_layers(),
|
||||
Some("wasm") => cmd_wasm(),
|
||||
Some("ci") => cmd_ci(),
|
||||
Some("corpus") if rest.contains(&"--affinity") => affinity_corpus::fetch(),
|
||||
Some("corpus") => cmd_corpus(rest.contains(&"--download")),
|
||||
Some("bundle") => bundle::run(&root()),
|
||||
Some("ico") => ico::run(&rest),
|
||||
@@ -231,6 +235,8 @@ Tests that use a corpus skip cleanly when it is absent.
|
||||
corpus/tiff/ libtiff pics (optional)
|
||||
corpus/exr/ OpenEXR sample images (optional)
|
||||
corpus/raw/ raw.pixls.us samples, CC0 (optional)
|
||||
corpus/affinity/ public Affinity documents (CC0/MIT/Apache-2.0) at pinned commits, sha256
|
||||
verified: oracles for the Affinity reader. Fetch: cargo xtask corpus --affinity
|
||||
",
|
||||
corpus.display()
|
||||
);
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
//! SHA-256 (FIPS 180-4), std only, for verifying downloaded test corpora.
|
||||
|
||||
const K: [u32; 64] = [
|
||||
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
|
||||
0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
||||
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
|
||||
0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
||||
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
|
||||
0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
||||
];
|
||||
|
||||
/// Lower-case hex digest of `data`.
|
||||
pub fn hex(data: &[u8]) -> String {
|
||||
let mut h: [u32; 8] = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19];
|
||||
let mut msg = data.to_vec();
|
||||
let bits = (data.len() as u64).wrapping_mul(8);
|
||||
msg.push(0x80);
|
||||
while msg.len() % 64 != 56 {
|
||||
msg.push(0);
|
||||
}
|
||||
msg.extend(bits.to_be_bytes());
|
||||
for block in msg.chunks_exact(64) {
|
||||
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 in 16..64 {
|
||||
let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
|
||||
let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
|
||||
w[i] = w[i - 16].wrapping_add(s0).wrapping_add(w[i - 7]).wrapping_add(s1);
|
||||
}
|
||||
let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut hh] = h;
|
||||
for i in 0..64 {
|
||||
let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
|
||||
let ch = (e & f) ^ (!e & g);
|
||||
let t1 = hh.wrapping_add(s1).wrapping_add(ch).wrapping_add(K[i]).wrapping_add(w[i]);
|
||||
let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
|
||||
let maj = (a & b) ^ (a & c) ^ (b & c);
|
||||
let t2 = s0.wrapping_add(maj);
|
||||
(hh, g, f, e, d, c, b, a) = (g, f, e, d.wrapping_add(t1), c, b, a, t1.wrapping_add(t2));
|
||||
}
|
||||
for (x, y) in h.iter_mut().zip([a, b, c, d, e, f, g, hh]) {
|
||||
*x = x.wrapping_add(y);
|
||||
}
|
||||
}
|
||||
h.iter().map(|x| format!("{x:08x}")).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn known_digests() {
|
||||
assert_eq!(super::hex(b""), "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
|
||||
assert_eq!(super::hex(b"abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
|
||||
let million = vec![b'a'; 1_000_000];
|
||||
assert_eq!(super::hex(&million), "cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user