AF.3: native Affinity import with artboards, vectors, text and images
Open and Place read Affinity documents natively (.af from Affinity 3, .afdesign/.afphoto/.afpub from Affinity 1 and 2): layers and sublayers, groups, artboards with their names and backgrounds, Publisher pages side by side, curves with live corners, parametric shapes, compound shapes, clipping by nesting, vector and pixel masks, solid and gradient fills, strokes, fill layers, artistic and frame text, placed images (their original JPEG/PNG), pixel layers, embedded documents as their cached picture, opacity, visibility, locking and blend modes, at the document's resolution in points. Unsupported content (effects, adjustments, brushes, master pages, special shapes, Affinity-only blend modes...) is listed in the import warning, one line per kind. A file whose native data can't be read opens as its embedded preview with the reason, and that preview cannot be placed or used as a template or library. Save never writes back over the Affinity file and there is no Affinity export. The document semantics were fitted to the thumbnails Affinity embeds in 176 public documents. `cargo xtask corpus --affinity` fetches 22 public CC0/MIT/Apache-2.0 documents at pinned commits, sha256-verified; the new engine test renders each and compares it with Affinity's own thumbnail (0-4.4 of 255 mean difference, per-file ceilings). import_fuzz mutates a synthetic native document's stream and container through open, render and export; the fuzz crate gains a stream target. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
This commit is contained in:
parent
1c47480a09
commit
297a01b6aa
|
|
@ -194,9 +194,11 @@ milestones, and honest time-to-parity estimates.
|
|||
"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).
|
||||
Affinity `.af` files with a verified version-12 embedded PNG preview open as that preview,
|
||||
with a warning; native Affinity vectors, layers and full document resolution are not imported.
|
||||
Save makes a new VectorCraft copy. Place, templates and style/swatch library import are refused; export SVG, PDF or full-resolution PNG from Affinity first. [Preview scope and limits](crates/affinity/README.md).
|
||||
Affinity documents (`.af` from Affinity 3, `.afdesign`, `.afphoto` and `.afpub` 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 and Spanish (and Czech and Brazilian Portuguese in the menus).
|
||||
The scores are
|
||||
|
|
|
|||
13
ROADMAP.md
13
ROADMAP.md
|
|
@ -38,7 +38,7 @@ since 2026-10-01.
|
|||
| **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 |
|
||||
| **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first |
|
||||
| **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured |
|
||||
| **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai`, EPS, DXF, EMF/WMF, raster formats, PSD export, Place and Links, Package, Print, clipboard flavours. Affinity `.af` has a bounded version-12 preview reader only; native editable import and export are missing, so this does not increase the parity score. Native `.ai` private data is out of scope by design; DWG has no open spec |
|
||||
| **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai`, EPS, DXF, EMF/WMF, raster formats, PSD export, Place and Links, Package, Print, clipboard flavours. Affinity documents (Affinity 1–3) open natively as editable art, checked against Affinity's own thumbnails of 22 pinned public files; Affinity export is missing and effects, adjustments and brushes don't import. Affinity isn't an Illustrator feature, so this doesn't raise the parity score. 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 |
|
||||
|
|
@ -69,8 +69,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 and independently generated Affinity preview
|
||||
oracles (the initial `.af` reader imports only a verified embedded preview; editable native import remains open), 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
|
||||
|
|
@ -157,7 +158,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); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; a type object's stroke written as each glyph's outline stroked back on the type, so it stays one editable object; lines wrapped in a frame (each wrapped line ending in a space, one left edge, leading and font) back as one area type object, left aligned or justified with its paragraph spacing, where laying it out breaks the lines where the file does; glyphs set apart by gaps wider than a space (a barcode's digit groups) keeping their places; layers stacked as the pages mark them even when one first paints on a later page, art ending a layer's marked content or a clip closing in a later layer kept in its own layer, and layers with no art kept empty: #508; masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320).
|
||||
- EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export. EPS import runs Illustrator's AGM/CoolType prologs (resource categories, `resourceforall`, `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters, `cshow`, metadata skipped with `flushfile`), so Illustrator 30 EPS files open as vectors. It also runs what other generators write (Discord feedback, #466, #469): Illustrator 2026's procsets (`gcheck`, `rootfont`, `currentcolorrendering`: every Illustrator 2026 EPS fell back to its preview before), cairo/Inkscape (`status`/`flushfile`, images with a mask, `ImageType 3`, reusable streams), matplotlib's Type 3 fonts, Illustrator 8 EPS (stock art's usual format: executable strings such as its gradients' `ed`, `load` by integer keys, #474), Ghostscript's `//name` immediate names, `pdfmark`, `token`, `execform` and user paths. 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. 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 `.af` preview import: Open follows the container's indexed version-12 `Thmb` PNG record, verify dimensions and decoded pixels with limits, and report that native vectors, layers, text, effects, pages and colour settings were omitted. The preview can be downscaled; files without this verified record fail. Open drops the source save path, and Affinity export is unsupported. Place (including queued or renamed files) is refused so the preview cannot silently replace native art. This is an initial reader, not editable native support; [scope, provenance and remaining work](crates/affinity/README.md).
|
||||
- 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).
|
||||
- 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.
|
||||
- **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so any GPU that can show the window will do. Preferences › Performance › Graphics Processor (`gpuPreference`) is Automatic by default: the power-saving GPU on Windows and macOS (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306), and the GPU the desktop runs on under Linux, where KWin on an NVIDIA GPU refused frames from a Ryzen's integrated GPU and 0.5.0 crashed at startup (#502). Power Saving and High Performance pick a kind of GPU; `WGPU_POWER_PREF`, `WGPU_ADAPTER_NAME` and `WGPU_BACKEND` override them. The app picks among the adapters that can present to the window (native backends before OpenGL, hardware before software), and if the window fails on one while starting up it starts again without it instead of crashing; Help › About, `ui.inspect` and the log name the adapter in use. When the browser drops the page's WebGPU device or WebGL context, the web build writes recovery copies, starts again on new graphics with the same app and uploads its textures again; if it can't, it removes the stale canvas and says to reload, which offers the copies back. The desktop app writes the copies and says to restart (#369). On Windows and Linux the tools' cursors are OS cursors (bitmaps rasterized once per display scale), which the system moves itself: painted into the window, they trailed the pointer by the frames waiting to be shown, and the window now queues one frame instead of two (#444). macOS and the web still paint them (macOS sizes cursor bitmaps in points, eframe has no bitmap cursors on the web). Pens and touch drive the tools as the mouse does, and a tap pressed and lifted within one frame clicks where it tapped (#491); pen pressure reaches the Liquify tools' Use Pressure Pen on Windows, while winit 0.30 reports none on macOS or Linux and egui none on the web. On Linux, see `docs/development.md` for pens under Wayland.
|
||||
|
|
@ -171,7 +172,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; Shaper, the remaining Pen modifier nuances) | 15–20 |
|
||||
| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork; gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool, Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries. Left: freeform and mixed spot/process gradients export as stops or process colours to SVG/PDF; confirm the Scale Strokes & Effects default | 4–8 |
|
||||
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info; AF.1 adds bounded Affinity version-12 preview import only (no editable native content). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, polish. Editable Affinity import/export is separate unestimated work | 3–6 (existing scope) |
|
||||
| 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, Area Type Options (with Width/Height), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel, Hebrew/Arabic bidirectional text and Paragraph Direction done; tab leaders, spell check, vertical type pending | 45–60 |
|
||||
|
|
@ -216,7 +217,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 presets/rearrange polish (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 | 82% | global/video rulers, smart-guide preference depth, 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) | 3–5 |
|
||||
| File formats | 6 | 85% | Affinity native vectors/layers and export (version-12 preview import only; native work unestimated), DWG (no open spec: DXF instead), PSD placement as layers, EPS checked against more real files (Illustrator 2026 and Inkscape files open as vectors; CorelDRAW, Affinity, LibreOffice, matplotlib and Ghostscript output checked with synthetic programs 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 (existing scope; Affinity native work unestimated) |
|
||||
| File formats | 6 | 85% | Affinity export, and the rest of Affinity import (effects, adjustments, brushes, master pages, live symbol instances), DWG (no open spec: DXF instead), PSD placement as layers, EPS checked against more real files (Illustrator 2026 and Inkscape files open as vectors; CorelDRAW, Affinity, LibreOffice, matplotlib and Ghostscript output checked with synthetic programs 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 |
|
||||
|
|
|
|||
|
|
@ -1,82 +1,94 @@
|
|||
# vectorcraft-affinity
|
||||
|
||||
An independent Rust parser for a narrow part of the Affinity container: the indexed PNG
|
||||
preview observed in files saved by Affinity 3.3.0.4850 on macOS (container version 12).
|
||||
It uses the MIT OR Apache-2.0 `crc32fast` crate already present in the workspace lockfile,
|
||||
has no application-crate dependencies, and does not decompress or interpret the document graph.
|
||||
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.
|
||||
|
||||
This is **preview import**, not native editable Affinity support. The preview can be smaller
|
||||
than the document: a synthetic 1600 × 1200 document saved a 1024 × 768 preview. Files without
|
||||
the verified preview record fail with an actionable error. The parser makes no claim about
|
||||
every `.af` file, Windows saves, older `.afdesign`/`.afphoto` files or native export.
|
||||
## 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
|
||||
|
||||
The container header was researched against [VMDevCpp/afread](https://github.com/VMDevCpp/afread),
|
||||
MIT licensed, at `04b672334a43e3e37ded6b5ffc57af231d589774`. That reader supports container
|
||||
versions 7–11; it does not establish version-12 compatibility. This Rust implementation was
|
||||
written independently, with the version-12 preview record observed through synthetic documents
|
||||
created and saved by the public Affinity scripting SDK. No proprietary application source or
|
||||
GPL/AGPL reader code was used.
|
||||
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. Its
|
||||
meaning was then worked out from public documents only, without running Affinity: the semantics of
|
||||
shapes, paints, text and pixel data were fitted to the thumbnail every Affinity document embeds
|
||||
(Affinity's own render of it) on 176 public documents saved by Affinity 1.x, 2.x and 3.0/3.1 on
|
||||
Windows, macOS and iPad. No GPL/AGPL code (such as Inkscape's Affinity extension) was read, and no
|
||||
Affinity application code, asset or document is part of this repository.
|
||||
|
||||
## Accepted record
|
||||
## Validation
|
||||
|
||||
All container integers are little-endian. PNG integers are big-endian.
|
||||
* 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`):
|
||||
|
||||
| File offset | Field checked |
|
||||
|---|---|
|
||||
| 0 | Magic `00 ff 4b 41` |
|
||||
| 4 | `u16` version (inspection accepts 7–12; previews require 12) |
|
||||
| 6 | Zero flags |
|
||||
| 8 | Document class bytes `nsrP` (add-ons share the magic) |
|
||||
| 12 | `#Inf` |
|
||||
| 24 | `u64` thumbnail record offset |
|
||||
| 64 | `Prot` for versions after 7 |
|
||||
```sh
|
||||
cd crates/affinity
|
||||
cargo +nightly fuzz run stream -- -max_total_time=600
|
||||
```
|
||||
|
||||
At the thumbnail offset, the accepted record is `ff ff ff ff Thmb`, followed by `u32` record
|
||||
version 1, record length `PNG length + 13`, payload offset 29, zero, PNG length, encoding byte 1,
|
||||
then exactly that many PNG bytes. Unknown versions, flags, classes and record layouts fail.
|
||||
The parser follows only this offset; it never scans for PNG signatures in other resources or
|
||||
saved revisions.
|
||||
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.
|
||||
|
||||
Encoded previews are capped at 16 MiB and dimensions at 4096 pixels per side. Every offset,
|
||||
length and PNG chunk boundary is checked, every chunk CRC is validated (including IEND and
|
||||
ancillary chunks after IDAT), and the PNG must end exactly at IEND. The consuming
|
||||
engine additionally decodes the complete image pixels with a 128 MiB allocation
|
||||
limit before embedding it. It warns about omitted native content and drops the source save path.
|
||||
Compressed text/profile chunks (`zTXt`, `iTXt`, `iCCP`) and APNG chunks (`acTL`, `fcTL`, `fdAT`)
|
||||
are rejected: the verified native previews have none, and pixel budgets alone do not bound
|
||||
aggregate inflated metadata. IHDR is first and unique, IDAT is present and contiguous, PLTE is unique and precedes image data, unknown critical chunks fail, and IEND is last. Supporting additional layouts needs separate bounded decoding and oracles.
|
||||
## Safety limits
|
||||
|
||||
## Tests and next steps
|
||||
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, 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.
|
||||
|
||||
The leaf tests use synthetic envelopes and exercise truncations, hostile fields and random
|
||||
mutations. Engine tests exercise Open, placement rejection, warnings, raster validation and native save/reopen;
|
||||
`engine/tests/import_fuzz.rs` exercises import followed by render/export.
|
||||
## Privacy
|
||||
|
||||
A standalone `cargo-fuzz` target exercises both parser entry points:
|
||||
|
||||
```sh
|
||||
cd crates/affinity
|
||||
cargo +nightly fuzz run container -- -max_len=20971520 -max_total_time=60
|
||||
```
|
||||
|
||||
Seed it with independently generated synthetic envelopes and metadata-reviewed native files;
|
||||
keep local corpus/artifact files uncommitted. A short smoke campaign is not prolonged fuzzing.
|
||||
|
||||
Templates and document-derived style/swatch libraries reject Affinity instead of discarding the import warning. Placement is explicitly rejected by signature, even under another extension, in both direct and queued Place commands; their UI does not display successful import warnings. Export SVG/PDF or full-resolution PNG from Affinity before placing.
|
||||
|
||||
The ignored `native_affinity_oracle` engine test accepts an `AFFINITY_ORACLE_DIR` containing
|
||||
`synthetic-rectangle.af`/`.png` and `synthetic-large.af`/`.png`, generated by Affinity. Native
|
||||
oracles must be published through the shared corpus with a generator, verified metadata,
|
||||
SHA-256 manifest and pinned commit before becoming a repeatable CI gate. No binary fixtures
|
||||
belong in this repository.
|
||||
|
||||
Full editable import needs a separately bounded FAT/revision reader that resolves the current
|
||||
`doc.dat` revision chain, zstd/deflate streams with their predictors and checksums, and a typed-tag
|
||||
intermediate representation with object-reference, recursion and allocation bounds. Document
|
||||
mapping then needs geometry, units, transforms, pages/artboards, groups, curves/shapes, fills,
|
||||
gradients, strokes, clipping, embedded raster blobs, colour management, text/fonts with explicit
|
||||
fallbacks, effects and unsupported-feature warnings. Unknown data needs an explicit preservation
|
||||
or loss policy. Each supported version and feature needs independently generated native oracles.
|
||||
Native export and round-trip fidelity are separate work.
|
||||
Affinity documents can hold the folder they were saved in, user names, original image paths and
|
||||
XMP metadata. This reader never imports those fields.
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ cargo-fuzz = true
|
|||
|
||||
[dependencies]
|
||||
libfuzzer-sys = "0.4"
|
||||
vectorcraft-affinity = { path = ".." }
|
||||
vectorcraft-affinity = { path = "..", features = ["synth"] }
|
||||
|
||||
[[bin]]
|
||||
name = "container"
|
||||
|
|
@ -18,5 +18,12 @@ test = false
|
|||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "stream"
|
||||
path = "fuzz_targets/stream.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[workspace]
|
||||
members = ["."]
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@
|
|||
|
||||
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::inspect(bytes);
|
||||
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,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(),
|
||||
)
|
||||
}
|
||||
|
|
@ -12,12 +12,19 @@ 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.
|
||||
|
|
|
|||
|
|
@ -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,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,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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
//! Public Affinity documents, when present: `AFFINITY_SAMPLES=<dir>` points at a folder of
|
||||
//! `.af`/`.afdesign`/`.afphoto`/`.afpub` files (searched recursively). Without it this test does
|
||||
//! nothing; `cargo xtask corpus` fetches the pinned public set (see the crate README).
|
||||
//! 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};
|
||||
|
|
@ -12,17 +12,24 @@ fn files(dir: &Path, out: &mut Vec<PathBuf>) {
|
|||
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)) {
|
||||
} 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) = std::env::var_os("AFFINITY_SAMPLES") else { return };
|
||||
let Some(dir) = samples() else { return };
|
||||
let mut paths = Vec::new();
|
||||
files(Path::new(&dir), &mut paths);
|
||||
files(&dir, &mut paths);
|
||||
assert!(!paths.is_empty(), "no Affinity documents under {dir:?}");
|
||||
let mut failures = Vec::new();
|
||||
for p in &paths {
|
||||
|
|
@ -39,3 +46,38 @@ fn public_documents_parse_completely() {
|
|||
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"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ thiserror = { workspace = true }
|
|||
log = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
vectorcraft-affinity = { workspace = true, features = ["synth"] }
|
||||
crc32fast = { workspace = true }
|
||||
vectorcraft-testkit = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -1,14 +1,51 @@
|
|||
//! Explicit preview-only Affinity import; native vectors and document layers are not decoded.
|
||||
//! 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 vectorcraft_doc::Document;
|
||||
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;
|
||||
|
||||
pub(super) fn import(name: &str, bytes: &[u8]) -> Result<(Document, Vec<String>)> {
|
||||
let p = vectorcraft_affinity::preview(bytes).map_err(err)?;
|
||||
/// 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);
|
||||
|
|
@ -19,9 +56,454 @@ pub(super) fn import(name: &str, bytes: &[u8]) -> Result<(Document, Vec<String>)
|
|||
reader.limits(limits);
|
||||
let _pixels = reader.decode().map_err(err)?;
|
||||
let d = raster_doc(name, p.png)?;
|
||||
let warnings = vec![format!(
|
||||
"Opened only Affinity's embedded {}×{} PNG preview, which may be smaller than the document. Native vectors, layers, text, masks, effects, pages and the document's colour settings are not imported. Save a new copy; the Affinity source cannot be saved back. Export SVG or PDF from Affinity for editable vectors.",
|
||||
p.width, p.height
|
||||
)];
|
||||
Ok((d, warnings))
|
||||
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 { text: 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
|
||||
|
|
@ -138,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) => {
|
||||
|
|
@ -178,8 +182,9 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
|||
(doc, warnings, false)
|
||||
}
|
||||
"affinity" => {
|
||||
let (doc, warnings) = super::affinity::import(&title, bytes)?;
|
||||
(doc, warnings, false)
|
||||
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))),
|
||||
|
|
@ -192,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
|
||||
|
|
@ -228,9 +233,9 @@ pub fn open_bytes_with(s: &mut Session, name: &str, bytes: &[u8], path: Option<S
|
|||
/// from there) as a new untitled document.
|
||||
pub fn open_template(s: &mut Session, name: &str, bytes: &[u8], path: Option<&str>) -> Result<Value> {
|
||||
let loaded = load(name, bytes)?;
|
||||
if loaded.format.id == "affinity" {
|
||||
if loaded.preview_only {
|
||||
return Err(err(
|
||||
"Affinity templates are not supported: use File › Open to inspect the embedded preview with its warning, or export SVG, PDF or full-resolution PNG from Affinity first",
|
||||
"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)
|
||||
|
|
@ -238,7 +243,7 @@ pub fn open_template(s: &mut Session, name: &str, bytes: &[u8], path: Option<&st
|
|||
|
||||
/// Make a loaded file the new active document; `opts` are the options it was read with.
|
||||
fn open_loaded(s: &mut Session, loaded: Loaded, path: Option<String>, opts: &LoadOptions, as_template: bool) -> Result<Value> {
|
||||
let Loaded { mut doc, format, warnings, restored, converted } = loaded;
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -556,7 +556,7 @@ 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 },
|
||||
reader("affinity", "Affinity (embedded preview)", &["af"], "application/affinity", false),
|
||||
reader("affinity", "Affinity", &["af", "afdesign", "afphoto", "afpub"], "application/vnd.affinity", false),
|
||||
];
|
||||
|
||||
/// Every extension `document.open` reads (the "All readable files" filter of open dialogs).
|
||||
|
|
@ -582,6 +582,9 @@ pub const OPEN_EXTS: &[&str] = &[
|
|||
"wmf",
|
||||
"eps",
|
||||
"af",
|
||||
"afdesign",
|
||||
"afphoto",
|
||||
"afpub",
|
||||
];
|
||||
|
||||
/// The extension that picks each writable format when exporting (the format's first; PNG-8 shares
|
||||
|
|
@ -599,7 +602,7 @@ pub fn export_extensions() -> Vec<&'static str> {
|
|||
/// Text files: File → Place sets them as area type (Text Import Options).
|
||||
pub const TEXT_EXTS: &[&str] = &["txt"];
|
||||
|
||||
/// Every extension File → Place reads: [`OPEN_EXTS`] except preview-only Affinity, plus [`TEXT_EXTS`].
|
||||
/// Every extension File → Place reads: [`OPEN_EXTS`] and [`TEXT_EXTS`].
|
||||
pub const PLACE_EXTS: &[&str] = &[
|
||||
"vectorcraft",
|
||||
"drawcraft",
|
||||
|
|
@ -621,6 +624,10 @@ pub const PLACE_EXTS: &[&str] = &[
|
|||
"emf",
|
||||
"wmf",
|
||||
"eps",
|
||||
"af",
|
||||
"afdesign",
|
||||
"afphoto",
|
||||
"afpub",
|
||||
"txt",
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
//! Affinity is explicitly a preview-only reader, through the same commands and save policy.
|
||||
//! 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::*;
|
||||
|
|
@ -39,8 +40,8 @@ fn preview_opens_with_a_warning_and_never_saves_to_the_source() {
|
|||
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");
|
||||
assert!(result["warnings"][0].as_str().unwrap().contains("2×1"));
|
||||
assert!(result["warnings"][0].as_str().unwrap().contains("Native vectors"));
|
||||
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");
|
||||
|
|
@ -56,8 +57,9 @@ fn preview_opens_with_a_warning_and_never_saves_to_the_source() {
|
|||
}
|
||||
let f = format("affinity").unwrap();
|
||||
assert!(f.read && !f.write);
|
||||
assert!(OPEN_EXTS.contains(&"af"));
|
||||
assert!(!PLACE_EXTS.contains(&"af"));
|
||||
for ext in ["af", "afdesign", "afphoto", "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());
|
||||
|
|
@ -81,13 +83,12 @@ fn placing_a_preview_is_rejected_even_when_renamed_or_queued() {
|
|||
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("not supported"), "{e}");
|
||||
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("not supported"), "{e}");
|
||||
assert!(e.contains("File › Open") && e.contains("embedded preview"), "{e}");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().doc, original);
|
||||
assert!(place_filters().all(|(_, exts)| !exts.contains(&"af")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -169,21 +170,160 @@ proptest! {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "set AFFINITY_ORACLE_DIR to synthetic Affinity .af and exported PNG files"]
|
||||
fn native_affinity_oracle() {
|
||||
let dir = std::env::var("AFFINITY_ORACLE_DIR").unwrap();
|
||||
for (name, size) in [("synthetic-rectangle", (64.0, 48.0)), ("synthetic-large", (1024.0, 768.0))] {
|
||||
let bytes = std::fs::read(format!("{dir}/{name}.af")).unwrap();
|
||||
let r = load(&format!("{name}.af"), &bytes).unwrap();
|
||||
let ab = r.doc.artboards[0].rect;
|
||||
assert_eq!((ab.width(), ab.height()), size);
|
||||
let preview = vectorcraft_affinity::preview(&bytes).unwrap();
|
||||
assert_eq!(r.doc.images.values().next().unwrap().bytes.as_slice(), preview.png);
|
||||
if name == "synthetic-rectangle" {
|
||||
let source = image::load_from_memory(&std::fs::read(format!("{dir}/{name}.png")).unwrap()).unwrap().to_rgba8();
|
||||
let decoded = image::load_from_memory(preview.png).unwrap().to_rgba8();
|
||||
assert_eq!(decoded, source);
|
||||
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");
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -129,15 +129,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);
|
||||
// Place and the queued place cursor do not surface successful import warnings. An Affinity
|
||||
// preview must never silently stand in for the full document there, even under another name.
|
||||
if vectorcraft_affinity::is_affinity(&src.bytes) || fileio::format_for_name(src.name).is_some_and(|f| f.id == "affinity") {
|
||||
return Err(bad(
|
||||
cmd,
|
||||
"Affinity placement is not supported: use File › Open to inspect the embedded preview with its warning, or export SVG, PDF or full-resolution PNG from Affinity before placing",
|
||||
));
|
||||
}
|
||||
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")));
|
||||
|
|
@ -184,6 +177,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 (a native document's) show their files, found from its folder.
|
||||
super::links::resolve(&mut l.doc, src.path, false);
|
||||
(l.doc, l.warnings)
|
||||
|
|
|
|||
|
|
@ -223,8 +223,8 @@ fn load(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Some(t) => style_libs::read(t, stem(file)).map_err(|e| bad(C, e)),
|
||||
None => {
|
||||
let loaded = super::fileio::load(file, bytes).map_err(|e| bad(C, e.to_string()))?;
|
||||
if loaded.format.id == "affinity" {
|
||||
return Err(bad(C, "Affinity graphic styles are not decoded; use File › Open to inspect the embedded preview with its warning"));
|
||||
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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -355,8 +355,8 @@ fn load(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Some(t) => palette_io::read(t, stem(file)).map_err(|e| bad(C, e)),
|
||||
None => {
|
||||
let loaded = super::fileio::load(file, bytes).map_err(|e| bad(C, e.to_string()))?;
|
||||
if loaded.format.id == "affinity" {
|
||||
return Err(bad(C, "Affinity swatches are not decoded; use File › Open to inspect the embedded preview with its warning"));
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,10 +76,9 @@ fn text_files_report_and_queue_with_their_options() {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn place_reads_open_formats_and_text_except_affinity_previews() {
|
||||
fn place_reads_what_open_reads_and_text_files() {
|
||||
use cmd::fileio::{OPEN_EXTS, PLACE_EXTS, TEXT_EXTS};
|
||||
let open: Vec<_> = OPEN_EXTS.iter().copied().filter(|ext| *ext != "af").collect();
|
||||
assert_eq!(PLACE_EXTS, [open.as_slice(), TEXT_EXTS].concat());
|
||||
assert_eq!(PLACE_EXTS, [OPEN_EXTS, TEXT_EXTS].concat());
|
||||
let filters: Vec<_> = cmd::fileio::place_filters().collect();
|
||||
assert_eq!((filters.first(), filters.last()), (Some(&("All placeable files", PLACE_EXTS)), Some(&("Text", TEXT_EXTS))));
|
||||
assert!(!cmd::fileio::open_filters().any(|(_, exts)| exts.contains(&"txt")), "Open doesn't read text files");
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
@ -4,8 +4,8 @@
|
|||
//! must load as an error or as a document that then renders and exports, without a panic; nor may
|
||||
//! bitmaps, PDF and text pasted from other apps, nor EMF and WMF pictures (damaged files, records
|
||||
//! of every kind with random contents) opened, placed or pasted, nor EPS and PostScript files
|
||||
//! (damaged ones, hostile programs) read by the PostScript interpreter, nor indexed PNG previews
|
||||
//! in Affinity containers (truncated or mutated records and hostile image dimensions).
|
||||
//! (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).
|
||||
|
|
@ -51,9 +51,184 @@ fn affinity_preview_sample() -> Vec<u8> {
|
|||
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();
|
||||
|
|
|
|||
|
|
@ -89,8 +89,8 @@
|
|||
<key>LSHandlerRank</key><string>Alternate</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key><string>Affinity (embedded preview)</string>
|
||||
<key>CFBundleTypeExtensions</key><array><string>af</string></array>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue