mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-13 23:08:09 +00:00
Merge main into #1462; in_blend_space restores LAB_MIX and HDR instead of clearing them
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
This commit is contained in:
101 files changed
+6016
-117
No files matched your search
@@ -206,6 +206,8 @@ jobs:
|
||||
- { crate: codecs, name: decode_tiff }
|
||||
- { crate: codecs, name: decode_exr }
|
||||
- { crate: codecs, name: decode_heif }
|
||||
- { crate: affinity, name: preview }
|
||||
- { crate: affinity, name: import_preview, args: "--features io" }
|
||||
- { crate: format, name: load }
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -217,7 +219,7 @@ jobs:
|
||||
run: cargo install cargo-fuzz --locked
|
||||
- name: Fuzz
|
||||
working-directory: crates/${{ matrix.target.crate }}
|
||||
run: cargo fuzz run ${{ matrix.target.name }} -- -max_total_time=${{ inputs.fuzz_seconds }}
|
||||
run: cargo fuzz run ${{ matrix.target.name }} ${{ matrix.target.args }} -- -max_total_time=${{ inputs.fuzz_seconds }}
|
||||
- name: Upload crash artifacts
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -84,6 +84,7 @@ next to the files:
|
||||
| `corpus/psd/` (170 files) | Small selection of the psd-tools and ag-psd test files | Kota Yamaguchi; Agamnentzar | psd-tools (above) and [ag-psd `test/`](https://github.com/Agamnentzar/ag-psd/tree/master/test) | MIT (both) |
|
||||
| `corpus/heif/` (9 files, 0.1 MB) | HEIC/HEIF test files: `heic-rs/` checkerboards, RGB strips and a grid-tiled photo with EXIF and XMP (synthetic pixels encoded by macOS `sips`, each `.ref.png` Apple's decode); `pillow-heif/` the 10-bit RGBA `RGBA_10__29x100.heif` and its source `RGBA_16__29x100.png` | Thomas Braun (heic-rs); Pillow-Heif contributors | [heic-rs `tests/fixtures`](https://github.com/tbraun96/heic-rs/tree/main/tests/fixtures), [pillow-heif `tests/images`](https://github.com/bigcat88/pillow_heif/tree/master/tests/images) | MIT OR Apache-2.0 (heic-rs); BSD-3-Clause (pillow-heif) |
|
||||
| `corpus/exr/` (5 files, 2.3 MB) | The deep OpenEXR test images `11`, `42`, `64`, `multivariate` and `objectid.deep.exr` | Contributors to the OpenEXR Project | [openexr `src/test/bin/test_images`](https://github.com/AcademySoftwareFoundation/openexr/tree/main/src/test/bin/test_images) | BSD-3-Clause |
|
||||
| `corpus/affinity/` (21 files, 2.4 MB) | Public Affinity documents, unmodified: `vector-art/` (9), `afdesignload/` (9), `jac21/` (2) and `asset-store-template/` (1), each with its upstream licence next to it | samuel-etver; Nick Beeuwsaert; Jeremy Cantu; DAIKI (eviltwo) | [vector-art `simple/`](https://github.com/samuel-etver/vector-art/tree/255f8add3c8f0740196e22bd59502b811b532f0b/simple), [AFDesignLoad `testDesigns/`](https://github.com/NickBeeuwsaert/AFDesignLoad/tree/a18dd50a7079fb861a28835eeefa0d015f03f4a1/testDesigns), [Jac21/Branding `Logos/JC/DesignerFiles/Affinity`](https://github.com/Jac21/Branding/tree/57ae3f45bf4637f6927c0b07de25b922c55f944f/Logos/JC/DesignerFiles/Affinity), [AssetStoreTemplate `AssetStoreTemplate/`](https://github.com/eviltwo/AssetStoreTemplate/tree/f671981ee89d15526285b3cc87b4393d3defe644/AssetStoreTemplate) | CC0-1.0 (vector-art); MIT, Copyright (c) 2015 Nick Beeuwsaert; MIT, Copyright (c) 2018 Jeremy Cantu; MIT, Copyright (c) 2024 DAIKI |
|
||||
| `corpus/pngsuite/` | PngSuite | Willem van Schaik | <http://www.schaik.com/pngsuite/> | Public domain |
|
||||
|
||||
Files copied into `corpus/` by hand (tiff, exr, raw) must be MIT, BSD or CC0.
|
||||
Generated
+14
@@ -4009,6 +4009,16 @@ dependencies = [
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-affinity"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"proptest",
|
||||
"ruzstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-algo"
|
||||
version = "0.5.0"
|
||||
@@ -4126,6 +4136,7 @@ version = "0.5.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"flate2",
|
||||
"photocraft-affinity",
|
||||
"photocraft-algo",
|
||||
"photocraft-cms",
|
||||
"photocraft-codecs",
|
||||
@@ -4204,6 +4215,9 @@ name = "photocraft-io"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"photocraft-affinity",
|
||||
"photocraft-algo",
|
||||
"photocraft-cms",
|
||||
"photocraft-codecs",
|
||||
|
||||
@@ -20,6 +20,7 @@ photocraft-ops = { path = "crates/ops" }
|
||||
photocraft-psd = { path = "crates/psd" }
|
||||
photocraft-codecs = { path = "crates/codecs" }
|
||||
photocraft-raw = { path = "crates/raw" }
|
||||
photocraft-affinity = { path = "crates/affinity" }
|
||||
photocraft-compose = { path = "crates/compose" }
|
||||
photocraft-gpu = { path = "crates/gpu" }
|
||||
photocraft-paint = { path = "crates/paint" }
|
||||
|
||||
@@ -178,8 +178,8 @@ Every screenshot here is the real app at work on public-domain art, rendered off
|
||||
<table>
|
||||
<tr>
|
||||
<td width="33%" valign="top">
|
||||
<h4>🧰 34 tools</h4>
|
||||
Move · Rectangular and Elliptical Marquee · Lasso · Polygonal Lasso · Magnetic Lasso · Magic Wand · Quick Selection · Object Selection · Crop · Eyedropper · Brush · Pencil · Mixer Brush · Color Replacement · Eraser · Clone Stamp · Healing Brush · Spot Healing · History Brush · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Path Selection · Type · five Shape tools · Hand · Zoom
|
||||
<h4>🧰 52 tools</h4>
|
||||
Move · Rectangular and Elliptical Marquee · Lasso · Polygonal Lasso · Magnetic Lasso · Object Selection · Quick Selection · Magic Wand · Crop · Slice · Slice Select · Eyedropper · Ruler · Note · Count · Spot Healing Brush · Healing Brush · Patch · Content-Aware Move · Red Eye · Brush · Pencil · Mixer Brush · Clone Stamp · Pattern Stamp · History Brush · Eraser · Background Eraser · Magic Eraser · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Horizontal and Vertical Type · Path Selection · Direct Selection · Rectangle · Ellipse · Triangle · Polygon · Line · Custom Shape · Hand · Rotate View · Zoom
|
||||
</td>
|
||||
<td width="33%" valign="top">
|
||||
<h4>🖌️ A real brush engine</h4>
|
||||
@@ -199,7 +199,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
|
||||
</td>
|
||||
<td width="33%" valign="top">
|
||||
<h4>🗂️ Formats</h4>
|
||||
PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), SVG (opens as shape layers, places as a vector Smart Object), plus flat PNG, JPEG, TIFF, WebP (lossy and lossless), GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and AVIF, with symmetric read and write at 8, 16 and 32 bits, HEIC photos from iPhone and Mac (read; in official builds, an optional <code>--features heif</code> build feature), and the native <code>.pcraft</code> format.
|
||||
PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), SVG (opens as shape layers, places as a vector Smart Object), plus flat PNG, JPEG, TIFF, WebP (lossy and lossless), GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and AVIF, with symmetric read and write at 8, 16 and 32 bits, HEIC photos from iPhone and Mac (read; in official builds, an optional <code>--features heif</code> build feature), and the native <code>.pcraft</code> format. Affinity documents (<code>.af</code>, <code>.afdesign</code>, <code>.afphoto</code>, <code>.afpub</code>) open natively, read only, with artboards, layers, vectors, text, images and masks; effects, adjustments and other unsupported parts are listed in a warning, and Affinity export is unavailable.
|
||||
</td>
|
||||
<td width="33%" valign="top">
|
||||
<h4>🪄 The everyday essentials</h4>
|
||||
|
||||
@@ -340,7 +340,10 @@ fn same_dir(a: &Path, b: &Path) -> bool {
|
||||
}
|
||||
|
||||
fn is_input(p: &Path) -> bool {
|
||||
let known = |e: &str| matches!(e, "pcraft" | "psd" | "psb") || photocraft_codecs::from_extension(e).is_some_and(|f| photocraft_codecs::caps(f).read);
|
||||
let known = |e: &str| {
|
||||
matches!(e, "pcraft" | "psd" | "psb" | "af" | "afdesign" | "afphoto" | "afpub")
|
||||
|| photocraft_codecs::from_extension(e).is_some_and(|f| photocraft_codecs::caps(f).read)
|
||||
};
|
||||
p.is_file() && p.extension().is_some_and(|e| known(&e.to_string_lossy().to_ascii_lowercase()))
|
||||
}
|
||||
|
||||
|
||||
@@ -12,11 +12,12 @@ use wasm_bindgen::JsCast as _;
|
||||
|
||||
type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
/// Everything File › Open reads: PhotoCraft and Photoshop documents, flat images, and Photoshop
|
||||
/// brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries.
|
||||
/// Everything File › Open reads: PhotoCraft, Photoshop and Affinity documents, flat images, and
|
||||
/// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries.
|
||||
const OPEN_EXTS: &[&str] = &[
|
||||
"pcraft", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam",
|
||||
"pfm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase",
|
||||
"pfm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af",
|
||||
"afdesign", "afphoto", "afpub",
|
||||
];
|
||||
const CANVAS_ID: &str = "photocraft_canvas";
|
||||
|
||||
|
||||
@@ -13,11 +13,12 @@ use std::pin::Pin;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Everything File › Open reads: PhotoCraft and Photoshop documents, flat images, and Photoshop
|
||||
/// brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries.
|
||||
/// Everything File › Open reads: PhotoCraft, Photoshop and Affinity documents, flat images, and
|
||||
/// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries.
|
||||
const OPEN_EXTS: &[&str] = &[
|
||||
"pcraft", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam",
|
||||
"pfm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase",
|
||||
"pfm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af",
|
||||
"afdesign", "afphoto", "afpub",
|
||||
];
|
||||
|
||||
/// File › Save As formats: (filter name, extensions). The filter matching the suggested name's
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
- [PSD and PSB](formats/psd-psb.md)
|
||||
- [Raster formats](formats/raster-formats.md)
|
||||
- [SVG](formats/svg.md)
|
||||
- [Affinity documents](formats/affinity.md)
|
||||
- [The `.pcraft` format](formats/pcraft.md)
|
||||
|
||||
# Automation
|
||||
|
||||
@@ -5,7 +5,7 @@ The layer numbers below describe allowed dependency direction, not runtime privi
|
||||
| Layer | Crates | Responsibility |
|
||||
|---|---|---|
|
||||
| L0 | `geom`, `cms`, `color`, `raster` | Geometry, ICC processing, color/blend math, pixel formats, and copy-on-write tiles |
|
||||
| L0 standalone | `psd`, `codecs` | PSD/PSB and flat raster format parsing/encoding |
|
||||
| L0 standalone | `psd`, `codecs`, `affinity` | PSD/PSB and flat raster format parsing/encoding; bounded Affinity preview extraction |
|
||||
| L1 | `doc` | Pure-data documents, layers, masks, effects, adjustments, text, vectors, and smart objects |
|
||||
| L2 | `ops`, `paint`, `algo`, `text`, `vector` | History operations, painting, imaging algorithms, type, paths, and shapes |
|
||||
| L3 | `compose`, `gpu`, `format` | CPU composition, wgpu composition, and `.pcraft` persistence |
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Affinity documents
|
||||
|
||||
PhotoCraft opens native Affinity documents: `.af` files from Affinity 3 and `.afdesign`,
|
||||
`.afphoto` and `.afpub` files from Affinity 1 and 2. Layers come in as editable PhotoCraft layers:
|
||||
|
||||
- pages and artboards become artboards (Publisher spreads sit side by side);
|
||||
- layers and groups become groups;
|
||||
- curves and shapes (rectangles, ellipses, polygons, stars and the other geometric shapes) become
|
||||
shape layers with their fill, gradient and stroke;
|
||||
- artistic and frame text becomes type layers, drawn with the fonts installed on your computer;
|
||||
- placed JPEG and PNG images become embedded smart objects, and pixel layers become pixel layers;
|
||||
- vector and pixel masks, opacity, visibility, locks, names and blend modes are kept.
|
||||
|
||||
The document is 8-bit RGB at the Affinity document's resolution. What PhotoCraft can't reproduce is
|
||||
listed in a warning when the file opens, for example layer effects, adjustment layers and live
|
||||
filters, brush strokes (drawn as plain strokes), special shapes such as clouds and hearts (drawn as
|
||||
ellipses), master pages, and CMYK or Lab colour (converted to RGB without the document's profile).
|
||||
Nothing is dropped without a warning.
|
||||
|
||||
PhotoCraft can't write Affinity files. An opened Affinity document has no save path: Save asks for
|
||||
a new file (`.pcraft`, PSD, PNG…) and never writes over the Affinity file.
|
||||
|
||||
If the native data can't be read (a damaged file, or a layout PhotoCraft doesn't know), PhotoCraft
|
||||
opens the small PNG preview Affinity stores in the file instead, as a single pixel layer named
|
||||
“Affinity preview”, and the warning says why. That preview can't be placed or used by File ▸ Place,
|
||||
stacks, batch processing or other imports that can't show the warning. Export PSD or PNG from
|
||||
Affinity in that case.
|
||||
|
||||
See [the crate's notes](https://github.com/storytold/photocraft/blob/main/crates/affinity/README.md)
|
||||
for the full list of what is imported, how the reader was written and validated, and its safety
|
||||
limits.
|
||||
@@ -1,13 +1,14 @@
|
||||
# Format overview
|
||||
|
||||
External bytes enter PhotoCraft through three main paths:
|
||||
External bytes enter PhotoCraft through four main paths:
|
||||
|
||||
- `photocraft-psd` parses and writes PSD and PSB structures.
|
||||
- `photocraft-codecs` decodes and encodes flat raster formats.
|
||||
- `photocraft-format` loads and saves the native `.pcraft` bundle.
|
||||
- `photocraft-affinity` reads native Affinity documents (`.af`, `.afdesign`, `.afphoto`, `.afpub`): layers, artboards, vectors, text, images and masks; it does not write them.
|
||||
|
||||
`photocraft-io` maps PSD and flat images into the document model and exports documents back to those formats. It is the integration layer; parsing limits remain the responsibility of the underlying format crates.
|
||||
|
||||
Formats are a primary security boundary. File extension is not a trust signal, metadata can be attacker-controlled, and small compressed inputs can declare very large decoded outputs. Callers should use the bounded default APIs and preserve errors rather than retrying with unlimited limits.
|
||||
|
||||
See [PSD and PSB](psd-psb.md), [Raster formats](raster-formats.md), [SVG](svg.md), and [The `.pcraft` format](pcraft.md). Cross-format hardening guidance is in [Parser hardening](../security/parser-hardening.md).
|
||||
See [PSD and PSB](psd-psb.md), [Raster formats](raster-formats.md), [SVG](svg.md), [Affinity documents](affinity.md), and [The `.pcraft` format](pcraft.md). Cross-format hardening guidance is in [Parser hardening](../security/parser-hardening.md).
|
||||
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "photocraft-affinity"
|
||||
description = "Bounded reader for native Affinity documents: archive, object stream and document model"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
# Synthetic-container builders for tests and fuzz seeds; not an Affinity exporter.
|
||||
synth = []
|
||||
|
||||
[dependencies]
|
||||
crc32fast = "1.5"
|
||||
flate2 = { workspace = true }
|
||||
ruzstd = "0.9"
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
@@ -0,0 +1,149 @@
|
||||
# photocraft-affinity
|
||||
|
||||
An independent, bounded Rust reader for native Affinity documents: `.af` from Affinity 3 and
|
||||
`.afdesign`, `.afphoto` and `.afpub` from Affinity 1 and 2 (container versions 8 to 12). It reads
|
||||
the archive, the tagged object stream in `doc.dat` and the part of the document model listed below,
|
||||
and hands `photocraft-io` a document in pixels with every transform composed. It does not write
|
||||
Affinity files.
|
||||
|
||||
## What is imported
|
||||
|
||||
`photocraft-io` maps the model to an 8-bit RGB document at the Affinity document's resolution:
|
||||
|
||||
| Affinity | PhotoCraft |
|
||||
|---|---|
|
||||
| Pages, Publisher spreads | one artboard per spread, spreads side by side 64 px apart |
|
||||
| Artboards (`ShpN` with `ABEn`), with name, solid background, any transform | artboards (nested ones as groups with a vector mask) |
|
||||
| Layers (`Scop`), groups (`Grup`), pass-through or isolated | groups (pass-through or Normal) |
|
||||
| Curves (`PCrv`): cubic subpaths, closed flags, live corners (`CnrD`) | shape layers (multi-subpath curves fill even-odd) |
|
||||
| Rectangles with corner radii (relative or absolute), ellipses, polygons (smooth too), stars, square stars, pies, triangles, trapezoids | shape layers |
|
||||
| Compound shapes (`Comp`, add and subtract) | one even-odd shape |
|
||||
| A shape or curve with children clips them | group with the outline as vector mask, the shape at its bottom |
|
||||
| Vector masks (`AdCh` curves and shapes) | vector masks |
|
||||
| Pixel masks (`MRst`, attached or as a layer in a group) | layer masks |
|
||||
| Solid fills: RGBA, HSLA, CMYK, grey, Lab (D50) | shape fill (converted to RGB) |
|
||||
| Linear, elliptical and radial gradients with Affinity's midpoint bias | gradient fills |
|
||||
| Strokes: weight, scale with object, dash pattern, alignment, caps, joins, miter limit | shape strokes |
|
||||
| Fill layers (`FRst`) | filled rectangles |
|
||||
| Artistic and frame text: characters, font family/weight/style, size, tracking, fixed leading, colour, paragraph alignment, first baseline | type layers (rendered with the fonts installed) |
|
||||
| Placed images (`ImgN`): the original JPEG/PNG embedded in the file | embedded smart objects |
|
||||
| Pixel layers (`Rstr`): RGBA 8/16-bit and CMYK 8-bit tiles, cropped to their content | pixel layers |
|
||||
| Embedded documents and symbols (`EmbN`) | the picture Affinity cached of them |
|
||||
| Opacity, visibility, lock, names, blend modes | the same (Add as Linear Dodge) |
|
||||
|
||||
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, 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 (one of each is used),
|
||||
strokes behind the fill, outlined or scaled text, text fields such as page numbers, frames with
|
||||
columns or a curved outline, grey/Lab/32-bit pixels, CMYK and Lab colour (converted to RGB without
|
||||
the document's profile), facing pages and the Average, Negation, Reflect, Glow and Erase blend
|
||||
modes (as Normal).
|
||||
|
||||
Opened Affinity documents start without a save path: Save asks for a new copy and never writes
|
||||
back over the Affinity file. When the native document can't be read at all, `photocraft-io` opens
|
||||
the embedded PNG preview instead, as one pixel layer named “Affinity preview”, with a warning
|
||||
that names the reason. That preview can't be placed or used by imports that can't show the
|
||||
warning (smart-object decoding, stacks, batch, displacement maps, variables).
|
||||
|
||||
## Embedded preview
|
||||
|
||||
`preview(bytes)` reads only the thumbnail record the header's offset 24 points to (the same in
|
||||
container versions 8 to 12); it never scans for PNG signatures. All integers are little-endian.
|
||||
|
||||
| Record offset | Size | Observed value |
|
||||
|---|---:|---|
|
||||
| 0 | 8 | `ffffffff` followed by `Thmb` |
|
||||
| 8 | 4 | Record version 1 |
|
||||
| 12 | 4 | PNG byte length + 13 |
|
||||
| 16 | 4 | PNG start offset 29 |
|
||||
| 20 | 4 | Zero |
|
||||
| 24 | 4 | PNG byte length |
|
||||
| 28 | 1 | PNG encoding 1 |
|
||||
| 29 | declared length | Exactly one PNG |
|
||||
|
||||
The PNG is limited to 16 MiB and 4096 pixels a side. IHDR must come first and be unique, IDAT
|
||||
chunks contiguous, an optional PLTE unique and before IDAT, every chunk CRC must match and IEND must
|
||||
end the file; compressed metadata (`zTXt`, `iTXt`, `iCCP`), APNG chunks and unknown critical chunks
|
||||
are rejected. The I/O adapter decodes it with a 128 MiB allocation limit.
|
||||
|
||||
## Provenance and clean-room
|
||||
|
||||
Affinity has no published file-format specification, so this reader is built only from a public
|
||||
description of the format and from public files, never from Affinity itself. This is how
|
||||
PhotoCraft already treats formats that are only partly documented: `photocraft-psd` fills the gaps
|
||||
in Adobe's specification from the MIT-licensed psd-tools and ag-psd, and `photocraft-raw` recovered
|
||||
Nikon's compression tables by black-box analysis of CC0 sample files (see their READMEs).
|
||||
|
||||
* **No Affinity software.** Affinity was never downloaded, installed, run, scripted, screenshotted or
|
||||
disassembled for this work, and nothing from an Affinity installation (program code, resources,
|
||||
presets, fonts, colour profiles) was read. No file was made with Affinity for it: every sample
|
||||
is a document its author published.
|
||||
* **A public, permissively licensed description.** The container and object-stream layout was
|
||||
learned from [VMDevCpp/afread](https://github.com/VMDevCpp/afread) (MIT, at
|
||||
`04b672334a43e3e37ded6b5ffc57af231d589774`, written for container versions 7–11) and re-described
|
||||
in our own words before this Rust code was written; no code was translated. No GPL/AGPL code (such
|
||||
as Inkscape's Affinity extension) was read.
|
||||
* **Every structure checked against public files.** afread doesn't say how its author learned the
|
||||
format, so nothing here rests on it alone: each structure the reader relies on is confirmed by
|
||||
the public documents. Every archive entry carries a CRC-32 that must match, and every field in
|
||||
the object stream names its own type, so a misread layout fails loudly instead of producing
|
||||
plausible wrong data.
|
||||
* **Meaning fitted to Affinity's own pictures.** What shapes, paints, text and pixel data mean was
|
||||
worked out by comparing our render with the thumbnail every Affinity document embeds (Affinity's
|
||||
render of itself), on 176 public documents saved by Affinity 1.x, 2.x and 3.0/3.1 on Windows,
|
||||
macOS and iPad. The collection has since grown to 189 distinct documents, and the reader opens
|
||||
all of them. Most are published under CC0, MIT, BSD, Apache-2.0 or CC BY (-SA); the others
|
||||
(no licence stated, or GPL or non-commercial terms) were only opened locally to compare
|
||||
pictures, never committed or redistributed. The corpus test keeps the comparison for the pinned
|
||||
files, which are CC0, MIT or Apache-2.0 only.
|
||||
* **Nothing of Affinity's is in this repository**: no program code, assets or documents. The test
|
||||
files are fetched at pinned commits and sha256-verified, never committed.
|
||||
* **Read-only.** Nothing is written in Affinity's format; export waits until someone can check
|
||||
written files in Affinity itself.
|
||||
|
||||
The same reader is shared, as an independent copy, with VectorCraft's `vectorcraft-affinity`.
|
||||
|
||||
## Validation
|
||||
|
||||
* Unit tests on synthetic containers built by the `synth` feature (stored, zlib and zstd entries,
|
||||
checksums, budgets, cycles, hostile streams, every truncation) and property tests of random
|
||||
mutations, here and in `photocraft-io`'s `tests/affinity.rs`.
|
||||
* `cargo xtask corpus --affinity` fetches 21 public CC0/MIT 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; `photocraft-io`'s `tests/affinity_corpus.rs` imports and flattens each one and
|
||||
compares it with the thumbnail Affinity saved in it (mean difference 0–4.6 of 255, with a
|
||||
ceiling per file).
|
||||
* `fuzz/` has `cargo-fuzz` targets for the whole reader (`preview`), the object stream (`stream`)
|
||||
and the I/O import (`import_preview`); CI's fuzz job runs `preview` and `import_preview`:
|
||||
|
||||
```sh
|
||||
cd crates/affinity/fuzz
|
||||
cargo +nightly fuzz run stream -- -max_total_time=600
|
||||
cargo +nightly fuzz run import_preview --features io -- -max_total_time=600
|
||||
```
|
||||
|
||||
What has not been verified: files from Affinity builds or platforms outside the public set, files
|
||||
written by other applications, rotated or skewed images against Affinity's render, mask polarity
|
||||
against a render with and without the mask, and anything listed as a warning above. The `synth`
|
||||
builders write only what this reader accepts; Affinity has never opened their output, so they are
|
||||
test fixtures, not an exporter.
|
||||
|
||||
## Safety limits
|
||||
|
||||
Every size is checked before it is allocated: 256 MiB per archive entry and 1 GiB per import by
|
||||
default (`Limits`), a 64 MiB zstd window, 4096 saved revisions, array lengths no longer than the
|
||||
bytes left, 16 777 216 decoded values per document stream (fields and array elements together, counted before
|
||||
anything is allocated: a value takes about 40 bytes in memory however few it took in the file; the largest of
|
||||
those 189 public documents uses 3.3 million), 384 levels of object nesting, 128 levels of layers, 500 000 layers, four million curve
|
||||
nodes per curve, 64 megapixels per pixel layer (cropped to its content first) and 1024 gradient
|
||||
stops; `photocraft-io` adds 300 000 pixels a side per document and 64 megapixels per image on the
|
||||
canvas. Every archive entry's CRC-32 and size must match. Malformed input returns an `Error`; the
|
||||
crate has no panics outside tests.
|
||||
|
||||
## Privacy
|
||||
|
||||
Affinity documents can hold the folder they were saved in, user names, original image paths and
|
||||
XMP metadata. This reader never imports those fields.
|
||||
@@ -0,0 +1,5 @@
|
||||
target/
|
||||
corpus/
|
||||
artifacts/
|
||||
coverage/
|
||||
Cargo.lock
|
||||
@@ -0,0 +1,41 @@
|
||||
[package]
|
||||
name = "photocraft-affinity-fuzz"
|
||||
version = "0.0.0"
|
||||
publish = false
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
[package.metadata]
|
||||
cargo-fuzz = true
|
||||
|
||||
[workspace]
|
||||
|
||||
[features]
|
||||
io = ["dep:photocraft-io"]
|
||||
|
||||
[dependencies]
|
||||
libfuzzer-sys = "0.4"
|
||||
photocraft-affinity = { path = "..", features = ["synth"] }
|
||||
photocraft-io = { path = "../../io", optional = true }
|
||||
|
||||
[[bin]]
|
||||
name = "preview"
|
||||
path = "fuzz_targets/preview.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "import_preview"
|
||||
path = "fuzz_targets/import_preview.rs"
|
||||
required-features = ["io"]
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "stream"
|
||||
path = "fuzz_targets/stream.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
@@ -0,0 +1,10 @@
|
||||
#![no_main]
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
// The complete import: native document mapping, or the preview fallback.
|
||||
if photocraft_affinity::is_affinity(data) {
|
||||
let _ = photocraft_io::import("fuzz.af", data);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
#![no_main]
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
|
||||
// The whole native reader (archive, object stream, model) and the preview, on arbitrary bytes.
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let _ = photocraft_affinity::container::header(data);
|
||||
let _ = photocraft_affinity::preview(data);
|
||||
let _ = photocraft_affinity::read(data, photocraft_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 _ = photocraft_affinity::stream::parse(stream);
|
||||
let file = photocraft_affinity::synth::container(&[("doc.dat", stream, photocraft_affinity::synth::Method::Stored)], None);
|
||||
let _ = photocraft_affinity::read(&file, photocraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20 });
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
//! Typed reads of [`Stream`] objects. Every accessor returns `None` when a field is missing or has
|
||||
//! another type, so the document mapping treats odd data as absent rather than failing.
|
||||
|
||||
use crate::stream::{ObjId, Object, Stream, Tag, Value};
|
||||
|
||||
impl Stream {
|
||||
pub fn object(&self, id: ObjId) -> Option<&Object> {
|
||||
self.objects.get(id)
|
||||
}
|
||||
|
||||
pub fn field(&self, id: ObjId, tag: &[u8; 4]) -> Option<&Value> {
|
||||
self.object(id)?.get(Tag::of(tag))
|
||||
}
|
||||
|
||||
pub fn class(&self, id: ObjId) -> Option<Tag> {
|
||||
self.object(id).map(|o| o.class)
|
||||
}
|
||||
|
||||
pub fn is(&self, id: ObjId, class: &[u8; 4]) -> bool {
|
||||
self.class(id) == Some(Tag::of(class))
|
||||
}
|
||||
|
||||
/// A child object (shared, linked or inline); `None` for null or missing.
|
||||
pub fn obj(&self, id: ObjId, tag: &[u8; 4]) -> Option<ObjId> {
|
||||
match self.field(id, tag)? {
|
||||
Value::Obj(o) => *o,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// An array of objects (nulls skipped).
|
||||
pub fn objs(&self, id: ObjId, tag: &[u8; 4]) -> Vec<ObjId> {
|
||||
match self.field(id, tag) {
|
||||
Some(Value::Array(a)) => a.iter().filter_map(|v| if let Value::Obj(Some(o)) = v { Some(*o) } else { None }).collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn f64(&self, id: ObjId, tag: &[u8; 4]) -> Option<f64> {
|
||||
match self.field(id, tag)? {
|
||||
Value::Float(f) if f.is_finite() => Some(*f),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn int(&self, id: ObjId, tag: &[u8; 4]) -> Option<i64> {
|
||||
match self.field(id, tag)? {
|
||||
Value::Int(i) => Some(*i),
|
||||
Value::UInt(u) => i64::try_from(*u).ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bool(&self, id: ObjId, tag: &[u8; 4]) -> Option<bool> {
|
||||
match self.field(id, tag)? {
|
||||
Value::Bool(b) => Some(*b),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn str(&self, id: ObjId, tag: &[u8; 4]) -> Option<&str> {
|
||||
match self.field(id, tag)? {
|
||||
Value::Str(s) => Some(s),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn enumeration(&self, id: ObjId, tag: &[u8; 4]) -> Option<(u16, u16)> {
|
||||
match self.field(id, tag)? {
|
||||
Value::Enum { id, version } => Some((*id, *version)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A fixed vector of `N` finite floats (points, rectangles, transforms).
|
||||
pub fn floats<const N: usize>(&self, id: ObjId, tag: &[u8; 4]) -> Option<[f64; N]> {
|
||||
match self.field(id, tag)? {
|
||||
Value::Floats(v) if v.len() == N && v.iter().all(|f| f.is_finite()) => {
|
||||
let mut a = [0.0; N];
|
||||
a.copy_from_slice(v);
|
||||
Some(a)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bytes(&self, id: ObjId, tag: &[u8; 4]) -> Option<&[u8]> {
|
||||
match self.field(id, tag)? {
|
||||
Value::Bytes(b) | Value::Blob(b) => Some(b),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn entry(&self, id: ObjId, tag: &[u8; 4]) -> Option<&str> {
|
||||
match self.field(id, tag)? {
|
||||
Value::Entry(s) => Some(s),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Untagged fields of a positional object, in order.
|
||||
pub fn positional(&self, id: ObjId) -> impl Iterator<Item = &Value> {
|
||||
self.object(id).into_iter().flat_map(|o| o.fields.iter().map(|(_, v)| v))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
//! The Affinity archive: header, the chain of file allocation tables (one per save) and
|
||||
//! bounded, CRC-checked extraction of named entries such as `doc.dat` and raster tiles.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::io::Read;
|
||||
|
||||
use crate::Error;
|
||||
|
||||
pub const MAGIC: &[u8; 4] = b"\x00\xffKA";
|
||||
/// Oldest and newest container versions seen in real documents (Affinity 1.x writes 8,
|
||||
/// Affinity 3 writes 12). Version 7 is described by afread but no sample exists.
|
||||
pub const VERSIONS: std::ops::RangeInclusive<u16> = 8..=12;
|
||||
/// `Prsn` (stored byte-reversed): documents. Asset, brush and style libraries share the magic.
|
||||
const DOCUMENT_KIND: &[u8; 4] = b"nsrP";
|
||||
|
||||
/// A save appends one table; real files have one to a few hundred.
|
||||
const MAX_TABLES: usize = 4096;
|
||||
/// zstd window ceiling: real documents use a few MiB; this stops a header from reserving gigabytes.
|
||||
const MAX_ZSTD_WINDOW: u64 = 64 << 20;
|
||||
|
||||
/// Decompression budget for one import. Every entry declares its size, which is checked against
|
||||
/// these limits before anything is allocated, and the decoded length must match it exactly.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Limits {
|
||||
/// Largest single entry (`doc.dat` or one tile).
|
||||
pub max_entry: usize,
|
||||
/// Total bytes extracted over the whole import.
|
||||
pub max_total: usize,
|
||||
}
|
||||
|
||||
impl Default for Limits {
|
||||
fn default() -> Self {
|
||||
Self { max_entry: 256 << 20, max_total: 1 << 30 }
|
||||
}
|
||||
}
|
||||
|
||||
/// The container header, all integers little-endian.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Header {
|
||||
pub version: u16,
|
||||
/// Seen as 0, 4 or 8 in real files; bits 0 and 1 mark container variants that are rejected.
|
||||
pub flags: u16,
|
||||
pub(crate) fat_offset: u64,
|
||||
pub(crate) thumbnail_offset: u64,
|
||||
/// Save time in Unix seconds, as written by the application.
|
||||
pub saved: u32,
|
||||
}
|
||||
|
||||
/// One FAT record, as of the newest table that mentions its id.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Entry {
|
||||
pub id: u32,
|
||||
pub name: String,
|
||||
offset: u64,
|
||||
size: u64,
|
||||
compressed: u64,
|
||||
crc: u32,
|
||||
compression: u8,
|
||||
}
|
||||
|
||||
impl Entry {
|
||||
/// Decompressed size declared by the table.
|
||||
pub fn size(&self) -> u64 {
|
||||
self.size
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Archive<'a> {
|
||||
bytes: &'a [u8],
|
||||
pub header: Header,
|
||||
/// Head revision of every named, non-deleted entry.
|
||||
entries: HashMap<String, Entry>,
|
||||
/// Number of tables (saves) in the chain.
|
||||
pub revisions: usize,
|
||||
extracted: usize,
|
||||
limits: Limits,
|
||||
}
|
||||
|
||||
pub(crate) struct Cursor<'a> {
|
||||
bytes: &'a [u8],
|
||||
pub(crate) pos: usize,
|
||||
}
|
||||
|
||||
impl<'a> Cursor<'a> {
|
||||
pub(crate) fn new(bytes: &'a [u8], pos: usize) -> Self {
|
||||
Self { bytes, pos }
|
||||
}
|
||||
pub(crate) fn remaining(&self) -> usize {
|
||||
self.bytes.len().saturating_sub(self.pos)
|
||||
}
|
||||
pub(crate) fn take(&mut self, n: usize) -> Result<&'a [u8], Error> {
|
||||
let end = self.pos.checked_add(n).ok_or(Error::Malformed("length overflow"))?;
|
||||
let s = self.bytes.get(self.pos..end).ok_or(Error::Malformed("truncated data"))?;
|
||||
self.pos = end;
|
||||
Ok(s)
|
||||
}
|
||||
pub(crate) fn array<const N: usize>(&mut self) -> Result<[u8; N], Error> {
|
||||
let s = self.take(N)?;
|
||||
let mut a = [0u8; N];
|
||||
a.copy_from_slice(s);
|
||||
Ok(a)
|
||||
}
|
||||
pub(crate) fn u8(&mut self) -> Result<u8, Error> {
|
||||
Ok(self.array::<1>()?[0])
|
||||
}
|
||||
pub(crate) fn u16(&mut self) -> Result<u16, Error> {
|
||||
Ok(u16::from_le_bytes(self.array()?))
|
||||
}
|
||||
pub(crate) fn u32(&mut self) -> Result<u32, Error> {
|
||||
Ok(u32::from_le_bytes(self.array()?))
|
||||
}
|
||||
pub(crate) fn u64(&mut self) -> Result<u64, Error> {
|
||||
Ok(u64::from_le_bytes(self.array()?))
|
||||
}
|
||||
}
|
||||
|
||||
fn offset(v: u64) -> Result<usize, Error> {
|
||||
usize::try_from(v).map_err(|_| Error::Malformed("offset beyond addressable memory"))
|
||||
}
|
||||
|
||||
/// True when `bytes` start with the Affinity container signature (documents and libraries).
|
||||
pub fn is_affinity(bytes: &[u8]) -> bool {
|
||||
bytes.starts_with(MAGIC)
|
||||
}
|
||||
|
||||
/// Check the signature, version and document kind.
|
||||
pub fn header(bytes: &[u8]) -> Result<Header, Error> {
|
||||
if !is_affinity(bytes) {
|
||||
return Err(Error::Malformed("missing container signature"));
|
||||
}
|
||||
let mut c = Cursor::new(bytes, 4);
|
||||
let version = c.u16()?;
|
||||
let flags = c.u16()?;
|
||||
if !VERSIONS.contains(&version) {
|
||||
return Err(Error::Unsupported("unknown container version"));
|
||||
}
|
||||
if flags & 3 != 0 {
|
||||
return Err(Error::Unsupported("container variant"));
|
||||
}
|
||||
if c.take(4)? != DOCUMENT_KIND {
|
||||
return Err(Error::Unsupported("this Affinity file is a library (assets, brushes or styles), not a document"));
|
||||
}
|
||||
if c.take(4)? != b"#Inf" {
|
||||
return Err(Error::Malformed("missing information header"));
|
||||
}
|
||||
let fat_offset = c.u64()?;
|
||||
let thumbnail_offset = c.u64()?;
|
||||
c.take(16)?;
|
||||
let saved = u32::try_from(c.u64()?).unwrap_or(0);
|
||||
c.take(8)?;
|
||||
if c.take(4)? != b"Prot" {
|
||||
return Err(Error::Malformed("missing protocol header"));
|
||||
}
|
||||
Ok(Header { version, flags, fat_offset, thumbnail_offset, saved })
|
||||
}
|
||||
|
||||
impl<'a> Archive<'a> {
|
||||
pub fn open(bytes: &'a [u8], limits: Limits) -> Result<Self, Error> {
|
||||
let header = header(bytes)?;
|
||||
// Newest record per id: (table date, table index, record).
|
||||
let mut latest: HashMap<u32, (u64, usize, Option<Entry>)> = HashMap::new();
|
||||
let mut names: HashMap<u32, String> = HashMap::new();
|
||||
let mut seen = HashSet::new();
|
||||
let mut next = header.fat_offset;
|
||||
let mut tables = 0usize;
|
||||
while next != 0 {
|
||||
if !seen.insert(next) {
|
||||
return Err(Error::Malformed("allocation tables form a cycle"));
|
||||
}
|
||||
tables += 1;
|
||||
if tables > MAX_TABLES {
|
||||
return Err(Error::Limit("too many saved revisions"));
|
||||
}
|
||||
let mut c = Cursor::new(bytes, offset(next)?);
|
||||
let tag = c.array::<4>()?;
|
||||
let level = match &tag {
|
||||
b"#FAT" => 1,
|
||||
b"#FT2" => 2,
|
||||
b"#FT3" => 3,
|
||||
b"#FT4" => 4,
|
||||
_ => return Err(Error::Malformed("missing allocation table")),
|
||||
};
|
||||
next = c.u64()?;
|
||||
let date = c.u64()?;
|
||||
c.take(24)?;
|
||||
let files = c.u32()?;
|
||||
c.take(8)?;
|
||||
let dirs = c.u16()?;
|
||||
c.take(1)?;
|
||||
for _ in 0..files {
|
||||
let id = c.u32()?;
|
||||
let flag = c.u8()?;
|
||||
let mut record = None;
|
||||
match flag {
|
||||
0 | 1 => {
|
||||
let offset = c.u64()?;
|
||||
let size = c.u64()?;
|
||||
let compressed = c.u64()?;
|
||||
let crc = c.u32()?;
|
||||
let stored = c.u8()?;
|
||||
if level >= 2 {
|
||||
c.take(4)?;
|
||||
}
|
||||
if level >= 4 {
|
||||
c.take(4)?;
|
||||
}
|
||||
// Old tables number the methods; newer ones store the method byte itself.
|
||||
let compression = if level <= 2 {
|
||||
match stored {
|
||||
1 => 0x01,
|
||||
2 => 0x41,
|
||||
3 => 0x81,
|
||||
4 => 0xC1,
|
||||
_ => 0,
|
||||
}
|
||||
} else {
|
||||
stored
|
||||
};
|
||||
record = Some(Entry { id, name: String::new(), offset, size, compressed, crc, compression });
|
||||
}
|
||||
2 => {}
|
||||
_ => return Err(Error::Malformed("unknown allocation record")),
|
||||
}
|
||||
if flag == 0 {
|
||||
let len = usize::from(c.u16()?);
|
||||
let name = String::from_utf8_lossy(c.take(len)?).into_owned();
|
||||
names.entry(id).or_insert(name);
|
||||
}
|
||||
// Equal dates: the first table in the chain (the newest) wins, as in afread.
|
||||
let replace = latest.get(&id).is_none_or(|(d, _, _)| date > *d);
|
||||
if replace {
|
||||
latest.insert(id, (date, tables, record));
|
||||
}
|
||||
}
|
||||
for _ in 0..dirs {
|
||||
let len = usize::from(c.u16()?);
|
||||
c.take(10)?;
|
||||
c.take(len)?;
|
||||
}
|
||||
}
|
||||
let mut entries = HashMap::new();
|
||||
for (id, (_, _, record)) in latest {
|
||||
if let (Some(mut e), Some(name)) = (record, names.get(&id)) {
|
||||
e.name = name.clone();
|
||||
entries.insert(e.name.clone(), e);
|
||||
}
|
||||
}
|
||||
Ok(Self { bytes, header, entries, revisions: tables, extracted: 0, limits })
|
||||
}
|
||||
|
||||
pub fn entry(&self, name: &str) -> Option<&Entry> {
|
||||
self.entries.get(name)
|
||||
}
|
||||
|
||||
pub fn entry_names(&self) -> impl Iterator<Item = &str> {
|
||||
self.entries.keys().map(String::as_str)
|
||||
}
|
||||
|
||||
/// Bytes extracted so far, counted against [`Limits::max_total`].
|
||||
pub fn extracted(&self) -> usize {
|
||||
self.extracted
|
||||
}
|
||||
|
||||
/// Extract an entry by name: decompress, undo the byte predictor and verify the CRC.
|
||||
pub fn read(&mut self, name: &str) -> Result<Vec<u8>, Error> {
|
||||
let e = self.entries.get(name).ok_or(Error::Malformed("missing archive entry"))?.clone();
|
||||
let size = usize::try_from(e.size).map_err(|_| Error::Limit("archive entry too large"))?;
|
||||
if size > self.limits.max_entry {
|
||||
return Err(Error::Limit("archive entry too large"));
|
||||
}
|
||||
let total = self.extracted.checked_add(size).ok_or(Error::Limit("document too large"))?;
|
||||
if total > self.limits.max_total {
|
||||
return Err(Error::Limit("document too large"));
|
||||
}
|
||||
let mut c = Cursor::new(self.bytes, offset(e.offset)?);
|
||||
if c.take(4)? != b"#Fil" {
|
||||
return Err(Error::Malformed("archive entry has no data block"));
|
||||
}
|
||||
let method = e.compression & 3;
|
||||
let stored_len = if matches!(method, 1 | 2) { e.compressed } else { e.size };
|
||||
let data = c.take(offset(stored_len)?)?;
|
||||
let mut out = match method {
|
||||
1 => inflate(data, size)?,
|
||||
2 => unzstd(data, size)?,
|
||||
_ => data.to_vec(),
|
||||
};
|
||||
if out.len() != size {
|
||||
return Err(Error::Malformed("archive entry size mismatch"));
|
||||
}
|
||||
unpredict(&mut out, e.compression)?;
|
||||
if crc32fast::hash(&out) != e.crc {
|
||||
return Err(Error::Malformed("archive entry checksum mismatch"));
|
||||
}
|
||||
self.extracted = total;
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Read at most `size + 1` bytes so a stream that decodes past its declared size is caught
|
||||
/// without unbounded allocation.
|
||||
fn read_bounded(mut r: impl Read, size: usize) -> Result<Vec<u8>, Error> {
|
||||
let mut out = Vec::with_capacity(size.min(64 << 20));
|
||||
let cap = u64::try_from(size).unwrap_or(u64::MAX).saturating_add(1);
|
||||
r.by_ref().take(cap).read_to_end(&mut out).map_err(|_| Error::Malformed("corrupt compressed entry"))?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn inflate(data: &[u8], size: usize) -> Result<Vec<u8>, Error> {
|
||||
read_bounded(flate2::read::ZlibDecoder::new(data), size)
|
||||
}
|
||||
|
||||
fn unzstd(data: &[u8], size: usize) -> Result<Vec<u8>, Error> {
|
||||
let mut src = data;
|
||||
let mut out = Vec::new();
|
||||
// Entries may hold several concatenated frames.
|
||||
while !src.is_empty() {
|
||||
let left = size.checked_sub(out.len()).ok_or(Error::Malformed("archive entry size mismatch"))?;
|
||||
let decoder = ruzstd::decoding::StreamingDecoder::new_with_max_window_size(&mut src, MAX_ZSTD_WINDOW)
|
||||
.map_err(|_| Error::Malformed("corrupt compressed entry"))?;
|
||||
let frame = read_bounded(decoder, left)?;
|
||||
if frame.is_empty() {
|
||||
return Err(Error::Malformed("corrupt compressed entry"));
|
||||
}
|
||||
out.extend_from_slice(&frame);
|
||||
if out.len() > size {
|
||||
return Err(Error::Malformed("archive entry size mismatch"));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Undo the byte predictor selected by the high bits of the compression byte.
|
||||
fn unpredict(b: &mut [u8], compression: u8) -> Result<(), Error> {
|
||||
match (compression & 0xC0, compression & 0x20 != 0) {
|
||||
(0x00, _) => {}
|
||||
(0x40, _) => byte_delta(b),
|
||||
(0x80, false) => {
|
||||
let mut prev = 0u16;
|
||||
for pair in b.as_chunks_mut::<2>().0.iter_mut() {
|
||||
let v = u16::from_le_bytes(*pair).wrapping_add(prev);
|
||||
*pair = v.to_le_bytes();
|
||||
prev = v;
|
||||
}
|
||||
}
|
||||
(0x80, true) => {
|
||||
byte_delta(b);
|
||||
// A 64 KiB tile of 16-bit samples is stored as all high bytes, then all low bytes.
|
||||
if b.len() == 0x10000 {
|
||||
let planes = b.to_vec();
|
||||
let (hi, lo) = planes.split_at(0x8000);
|
||||
for (i, out) in b.as_chunks_mut::<4>().0.iter_mut().enumerate() {
|
||||
let (Some(h), Some(l)) = (hi.get(2 * i..2 * i + 2), lo.get(2 * i..2 * i + 2)) else {
|
||||
return Err(Error::Malformed("tile size"));
|
||||
};
|
||||
*out = [l[0], h[0], l[1], h[1]];
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => return Err(Error::Unsupported("archive entry predictor")),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn byte_delta(b: &mut [u8]) {
|
||||
let mut prev = 0u8;
|
||||
for v in b {
|
||||
*v = v.wrapping_add(prev);
|
||||
prev = *v;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
//! Outlines of curves (`PCvD` node lists), parametric shapes (`Shpe` objects) and compounds, in
|
||||
//! node space.
|
||||
|
||||
use crate::model::{Affine, Path, Point, Reader, SubPath};
|
||||
use crate::stream::{ObjId, Tag, Value};
|
||||
|
||||
/// Nodes per curve object.
|
||||
const MAX_NODES: usize = 4_000_000;
|
||||
|
||||
/// The node-space outline of a curve, shape or compound node, and whether it fills even-odd.
|
||||
/// Instances (master-page or symbol copies) take the geometry of their master.
|
||||
pub(crate) fn outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine) -> Option<(Path, bool)> {
|
||||
let s = r.s;
|
||||
match &class.0.to_be_bytes() {
|
||||
b"PCrv" | b"TxtC" => {
|
||||
let crvs = s.obj(id, b"Crvs").or_else(|| s.obj(id, b"MCrM").and_then(|m| s.obj(m, b"Crvs")))?;
|
||||
let path = curves(r, crvs)?;
|
||||
let multi = path.subpaths.len() > 1;
|
||||
Some((path, multi))
|
||||
}
|
||||
b"Comp" => {
|
||||
// One path of all the operands' contours in the compound's space, filled even-odd: this
|
||||
// matches add and subtract operands in every public sample; a true union of overlapping
|
||||
// added operands would differ.
|
||||
let mut path = Path::default();
|
||||
for c in s.objs(id, b"Chld") {
|
||||
let local = s.floats::<6>(c, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY);
|
||||
let class = s.class(c)?;
|
||||
if s.enumeration(c, b"ComO").is_some_and(|(op, _)| op != 0 && op != 2) {
|
||||
r.warn("compound shapes that intersect or exclude (imported as add)");
|
||||
}
|
||||
if let Some((p, _)) = outline(r, c, class, local.then(world)) {
|
||||
path.subpaths.extend(p.transformed(local).subpaths);
|
||||
}
|
||||
}
|
||||
Some((path, true))
|
||||
}
|
||||
_ => {
|
||||
let (shape, box_owner) = match s.obj(id, b"Shpe") {
|
||||
Some(sh) => (sh, id),
|
||||
None => {
|
||||
let master = s.obj(id, b"ShpM")?;
|
||||
(s.obj(master, b"Shpe")?, master)
|
||||
}
|
||||
};
|
||||
let b = s.floats::<4>(box_owner, b"ShpB")?;
|
||||
crate::shapes::shape(r, shape, b, world).map(|p| (p, false))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `PCvD.Data`: positional `[u8, u32 count, then per subpath: bool closed, 18-byte records]`.
|
||||
/// A record is (f64 x, f64 y, u8 kind, u8 role): role 0 is an anchor, 1 the out-handle of the
|
||||
/// anchor before it, 2 the in-handle of the anchor after it. A closed subpath repeats its first
|
||||
/// anchor at the end. `CnrD` live corners are rounded on the stored polyline.
|
||||
pub(crate) fn curves(r: &mut Reader, crvs: ObjId) -> Option<Path> {
|
||||
let s = r.s;
|
||||
let data = s.obj(crvs, b"Data")?;
|
||||
let mut fields = s.positional(data);
|
||||
fields.next()?;
|
||||
let count = match fields.next()? {
|
||||
Value::UInt(n) => usize::try_from(*n).ok()?,
|
||||
_ => return None,
|
||||
};
|
||||
let corners = s.obj(crvs, b"CnrD").and_then(|c| corner_radii(s.positional(c).collect()));
|
||||
let mut path = Path::default();
|
||||
let mut seen = 0usize;
|
||||
for index in 0..count {
|
||||
let closed = match fields.next()? {
|
||||
Value::Bool(b) => *b,
|
||||
_ => return None,
|
||||
};
|
||||
let Value::Array(records) = fields.next()? else { return None };
|
||||
seen = seen.checked_add(records.len())?;
|
||||
if seen > MAX_NODES {
|
||||
r.warn("curves with more than four million nodes");
|
||||
return None;
|
||||
}
|
||||
let anchors = anchors(records)?;
|
||||
let radii = if index == 0 { corners.as_ref() } else { None };
|
||||
if let Some(sp) = subpath(&anchors, closed, radii) {
|
||||
path.subpaths.push(sp);
|
||||
}
|
||||
}
|
||||
if corners.is_some() && count > 1 {
|
||||
r.warn("rounded corners on curves with several subpaths (only the first is rounded)");
|
||||
}
|
||||
Some(path)
|
||||
}
|
||||
|
||||
struct Anchor {
|
||||
p: Point,
|
||||
inh: Option<Point>,
|
||||
out: Option<Point>,
|
||||
/// Index of the anchor's record (live corners refer to records).
|
||||
record: usize,
|
||||
}
|
||||
|
||||
fn record(v: &Value) -> Option<(Point, u8)> {
|
||||
let Value::Bytes(b) = v else { return None };
|
||||
if b.len() != 18 {
|
||||
return None;
|
||||
}
|
||||
let x = f64::from_le_bytes(b.get(0..8)?.try_into().ok()?);
|
||||
let y = f64::from_le_bytes(b.get(8..16)?.try_into().ok()?);
|
||||
if !x.is_finite() || !y.is_finite() {
|
||||
return None;
|
||||
}
|
||||
Some((Point { x, y }, *b.get(17)?))
|
||||
}
|
||||
|
||||
fn anchors(records: &[Value]) -> Option<Vec<Anchor>> {
|
||||
let mut out: Vec<Anchor> = Vec::with_capacity(records.len() / 3 + 1);
|
||||
let mut pending_in = None;
|
||||
for (i, v) in records.iter().enumerate() {
|
||||
let (p, role) = record(v)?;
|
||||
match role {
|
||||
0 => {
|
||||
out.push(Anchor { p, inh: pending_in.take(), out: None, record: i });
|
||||
}
|
||||
1 => {
|
||||
// A leading out-handle without an anchor is edit state only.
|
||||
if let Some(a) = out.last_mut() {
|
||||
a.out = Some(p);
|
||||
}
|
||||
}
|
||||
2 => pending_in = Some(p),
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn subpath(anchors: &[Anchor], closed: bool, radii: Option<&Vec<(usize, f64)>>) -> Option<SubPath> {
|
||||
let first = anchors.first()?;
|
||||
if let Some(radii) = radii.filter(|r| !r.is_empty()) {
|
||||
let corner = |a: &Anchor| radii.iter().filter(|(rec, _)| *rec == a.record).map(|(_, r)| *r).fold(0.0, f64::max);
|
||||
let polyline = anchors.iter().all(|a| a.inh.is_none_or(|h| h == a.p) && a.out.is_none_or(|h| h == a.p));
|
||||
if polyline {
|
||||
let mut pts: Vec<(Point, f64)> = anchors.iter().map(|a| (a.p, corner(a))).collect();
|
||||
// A closed subpath repeats its first anchor; its radius may be on either copy.
|
||||
if closed
|
||||
&& pts.len() > 1
|
||||
&& pts.first().map(|p| p.0) == pts.last().map(|p| p.0)
|
||||
&& let Some((_, r)) = pts.pop()
|
||||
&& let Some(f) = pts.first_mut()
|
||||
{
|
||||
f.1 = f.1.max(r);
|
||||
}
|
||||
return Some(crate::shapes::fillet(&pts, closed));
|
||||
}
|
||||
}
|
||||
let segments = anchors
|
||||
.windows(2)
|
||||
.filter_map(|w| {
|
||||
let [a, b] = w else { return None };
|
||||
Some([a.out.unwrap_or(a.p), b.inh.unwrap_or(b.p), b.p])
|
||||
})
|
||||
.collect();
|
||||
Some(SubPath { start: first.p, segments, closed })
|
||||
}
|
||||
|
||||
/// `CnrD`: positional `[u8, i32[] record indices, f64[] radii, u8[] types]`.
|
||||
fn corner_radii(fields: Vec<&Value>) -> Option<Vec<(usize, f64)>> {
|
||||
let (Some(Value::Array(idx)), Some(Value::Array(rad))) = (fields.get(1), fields.get(2)) else { return None };
|
||||
Some(
|
||||
idx.iter()
|
||||
.zip(rad)
|
||||
.filter_map(|(i, r)| match (i, r) {
|
||||
(Value::Int(i), Value::Float(r)) if r.is_finite() && *r > 0.0 => Some((usize::try_from(*i).ok()?, *r)),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//! Reader for native Affinity documents (`.af`, and `.afdesign`/`.afphoto`/`.afpub` from
|
||||
//! Affinity 1 and 2): the archive container, the tagged object stream in `doc.dat`, and the
|
||||
//! subset of the document model PhotoCraft imports.
|
||||
//!
|
||||
//! Layouts follow the MIT-licensed afread reference and were checked against public documents
|
||||
//! saved by Affinity 1.x to 3.x (container versions 8 to 12); see the crate README for provenance
|
||||
//! and the exact supported subset. Nothing here writes Affinity files.
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
use std::fmt;
|
||||
|
||||
mod access;
|
||||
pub mod container;
|
||||
mod geometry;
|
||||
pub mod model;
|
||||
pub mod paint;
|
||||
pub mod preview;
|
||||
mod raster;
|
||||
mod shapes;
|
||||
pub mod stream;
|
||||
#[cfg(any(test, feature = "synth"))]
|
||||
pub mod synth;
|
||||
mod text;
|
||||
|
||||
pub use container::{Archive, Limits, MAGIC, is_affinity};
|
||||
pub use model::{Document, read};
|
||||
pub use preview::{MAX_PREVIEW_BYTES, MAX_PREVIEW_DIMENSION, Preview, preview};
|
||||
|
||||
/// Why a file could not be read. Messages are short reasons; callers add advice.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Error {
|
||||
Malformed(&'static str),
|
||||
Unsupported(&'static str),
|
||||
Limit(&'static str),
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Malformed(s) => write!(f, "damaged Affinity file: {s}"),
|
||||
Self::Unsupported(s) => write!(f, "unsupported Affinity file: {s}"),
|
||||
Self::Limit(s) => write!(f, "Affinity file exceeds a safety limit: {s}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,587 @@
|
||||
//! The document model PhotoCraft 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.as_chunks::<4>().0.iter().map(|c| f64::from(f32::from_le_bytes(*c))).collect()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A colour object (`RGBA`, `HSLA`, `CMYK`, `GRAY`, `LABA`, `Pant`).
|
||||
pub(crate) fn color(r: &mut Reader, id: ObjId) -> Option<Color> {
|
||||
let s = r.s;
|
||||
let class = s.class(id)?;
|
||||
let col = || -> Option<Vec<f64>> {
|
||||
let v = floats(s.field(id, b"_col")?)?;
|
||||
v.iter().all(|f| f.is_finite()).then_some(v)
|
||||
};
|
||||
Some(match &class.0.to_be_bytes() {
|
||||
b"RGBA" => {
|
||||
let v = col()?;
|
||||
Color::Rgb { r: *v.first()?, g: *v.get(1)?, b: *v.get(2)?, a: v.get(3)?.clamp(0.0, 1.0) }
|
||||
}
|
||||
b"HSLA" => {
|
||||
let v = col()?;
|
||||
let (r2, g, b) = hsl(*v.first()?, *v.get(1)?, *v.get(2)?);
|
||||
Color::Rgb { r: r2, g, b, a: v.get(3)?.clamp(0.0, 1.0) }
|
||||
}
|
||||
b"CMYK" => {
|
||||
let v = col()?;
|
||||
Color::Cmyk { c: *v.first()?, m: *v.get(1)?, y: *v.get(2)?, k: *v.get(3)?, a: v.get(4)?.clamp(0.0, 1.0) }
|
||||
}
|
||||
b"GRAY" => {
|
||||
let v = col()?;
|
||||
Color::Gray { v: *v.first()?, a: v.get(1)?.clamp(0.0, 1.0) }
|
||||
}
|
||||
b"LABA" => {
|
||||
let b = s.bytes(id, b"_col")?;
|
||||
let u = |i: usize| -> Option<f64> { Some(f64::from(u16::from_le_bytes([*b.get(2 * i)?, *b.get(2 * i + 1)?])) / 65535.0) };
|
||||
Color::Lab { l: 100.0 * u(0)?, a: 255.0 * u(1)? - 128.0, b: 255.0 * u(2)? - 128.0, alpha: u(3)? }
|
||||
}
|
||||
b"Pant" => {
|
||||
r.warn("spot colours (imported as their RGB equivalent)");
|
||||
return s.obj(id, b"srgb").and_then(|c| color(r, c)).or_else(|| s.obj(id, b"base").and_then(|c| color(r, c)));
|
||||
}
|
||||
b"RegC" => Color::Cmyk { c: 1.0, m: 1.0, y: 1.0, k: 1.0, a: 1.0 },
|
||||
_ => {
|
||||
r.warn("colours in an unknown model");
|
||||
return None;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn hsl(h: f64, s: f64, l: f64) -> (f64, f64, f64) {
|
||||
let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
|
||||
let hp = (h.rem_euclid(1.0)) * 6.0;
|
||||
let x = c * (1.0 - (hp % 2.0 - 1.0).abs());
|
||||
let (r, g, b) = match hp as u32 {
|
||||
0 => (c, x, 0.0),
|
||||
1 => (x, c, 0.0),
|
||||
2 => (0.0, c, x),
|
||||
3 => (0.0, x, c),
|
||||
4 => (x, 0.0, c),
|
||||
_ => (c, 0.0, x),
|
||||
};
|
||||
let m = l - c / 2.0;
|
||||
(r + m, g + m, b + m)
|
||||
}
|
||||
|
||||
/// A fill descriptor (`FDsc`) or, in Affinity 1 files, a bare `FilS`; `local` maps the node's
|
||||
/// space to document pixels.
|
||||
pub(crate) fn descriptor(r: &mut Reader, id: ObjId, local: Affine) -> Option<Paint> {
|
||||
let s = r.s;
|
||||
let (fill, unit) = if s.is(id, b"FDsc") {
|
||||
let unit = s.floats::<6>(id, b"FDeX").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY);
|
||||
(s.obj(id, b"FDeF")?, unit)
|
||||
} else {
|
||||
(id, Affine::IDENTITY)
|
||||
};
|
||||
match &s.class(fill)?.0.to_be_bytes() {
|
||||
b"FilN" => Some(Paint::None),
|
||||
b"FilS" => Some(Paint::Solid(color(r, s.obj(fill, b"Colr")?)?)),
|
||||
b"FilG" => {
|
||||
let kind = match s.enumeration(fill, b"Type").map(|(i, _)| i) {
|
||||
Some(0) | None => GradientKind::Linear,
|
||||
Some(1 | 2) => GradientKind::Radial,
|
||||
Some(3) => GradientKind::Conical,
|
||||
Some(_) => {
|
||||
r.warn("gradients of an unknown kind (imported as linear)");
|
||||
GradientKind::Linear
|
||||
}
|
||||
};
|
||||
let grad = s.obj(fill, b"Grad")?;
|
||||
let cols = s.objs(grad, b"Cols");
|
||||
let posn: Vec<Vec<f64>> = match s.field(grad, b"Posn") {
|
||||
Some(Value::Array(a)) => a.iter().filter_map(floats).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let mut stops = Vec::new();
|
||||
for (c, p) in cols.iter().zip(&posn).take(MAX_STOPS) {
|
||||
let color = color(r, *c)?;
|
||||
let offset = p.first().copied().filter(|v| v.is_finite()).unwrap_or(0.0).clamp(0.0, 1.0);
|
||||
let bias = p.get(1).copied().filter(|v| v.is_finite()).unwrap_or(0.5).clamp(0.0, 1.0);
|
||||
stops.push(Stop { offset, color, bias });
|
||||
}
|
||||
if stops.is_empty() {
|
||||
return Some(Paint::None);
|
||||
}
|
||||
Some(Paint::Gradient(Gradient { kind, stops, transform: unit.then(local) }))
|
||||
}
|
||||
b"FilB" => {
|
||||
r.warn("bitmap and pattern fills (left empty)");
|
||||
Some(Paint::None)
|
||||
}
|
||||
_ => {
|
||||
r.warn("fills of an unknown kind (left empty)");
|
||||
Some(Paint::None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn current(r: &Reader, id: ObjId, list: &[u8; 4], index: &[u8; 4]) -> Option<ObjId> {
|
||||
let items = r.s.objs(id, list);
|
||||
let i = r.s.int(id, index).and_then(|i| usize::try_from(i).ok()).unwrap_or(0);
|
||||
items.get(i).or_else(|| items.first()).copied()
|
||||
}
|
||||
|
||||
/// The fills of `list` (artboard backgrounds use `BFFl`).
|
||||
pub(crate) fn fills(r: &mut Reader, id: ObjId, local: Affine, list: &[u8; 4]) -> Vec<crate::paint::Paint> {
|
||||
let d = current(r, id, list, b"BFCr").or_else(|| r.s.obj(id, b"BFil"));
|
||||
d.and_then(|d| descriptor(r, d, local)).filter(|p| *p != Paint::None).into_iter().collect()
|
||||
}
|
||||
|
||||
/// A node's fill and stroke. `local` maps the node's space to document pixels.
|
||||
pub(crate) fn node_paint(r: &mut Reader, id: ObjId, local: Affine) -> (Vec<Paint>, Vec<Stroke>) {
|
||||
let s = r.s;
|
||||
let fills = fills(r, id, local, b"BFFl");
|
||||
if r.s.objs(id, b"BFFl").len() > 1 || r.s.objs(id, b"LIFl").len() > 1 {
|
||||
r.warn("several fills or strokes on one object (only the active one is imported)");
|
||||
}
|
||||
if s.f64(id, b"FOpc").is_some_and(|o| o < 0.999) {
|
||||
r.warn("fill opacity (imported at full fill opacity)");
|
||||
}
|
||||
if s.obj(id, b"Trns").is_some() {
|
||||
r.warn("transparency gradients (imported without them)");
|
||||
}
|
||||
let (pen, style) = if s.field(id, b"LIFl").is_some() {
|
||||
(current(r, id, b"LIFl", b"LICr"), current(r, id, b"LILn", b"LICr"))
|
||||
} else {
|
||||
(s.obj(id, b"PFil"), s.obj(id, b"LSty"))
|
||||
};
|
||||
let behind = matches!(s.field(id, b"DrwO"), Some(Value::Array(a)) if a.first() == Some(&Value::Enum { id: 1, version: 0 }));
|
||||
let stroke = match (pen, style) {
|
||||
(Some(pen), Some(style)) => stroke(r, pen, style, local, behind),
|
||||
_ => None,
|
||||
};
|
||||
(fills, stroke.into_iter().collect())
|
||||
}
|
||||
|
||||
fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, behind: bool) -> Option<Stroke> {
|
||||
let s = r.s;
|
||||
let paint = descriptor(r, pen, local)?;
|
||||
if paint == Paint::None {
|
||||
return None;
|
||||
}
|
||||
let lsty = if s.is(ldsc, b"LDsc") { s.obj(ldsc, b"LDeL")? } else { ldsc };
|
||||
let weight = s.f64(lsty, b"Wght").filter(|w| *w > 0.0 && *w < 1e7)?;
|
||||
// "Scale with object": the weight follows the object's transform; otherwise it is absolute.
|
||||
let scales = s.bool(ldsc, b"LDSc").unwrap_or(true);
|
||||
let width = if scales { weight * local.scale() } else { weight };
|
||||
let data = s.bytes(lsty, b"Data").unwrap_or_default();
|
||||
let miter_limit = data.get(0..8).and_then(|b| b.try_into().ok()).map(f64::from_le_bytes).filter(|m| m.is_finite() && *m >= 1.0).unwrap_or(4.0);
|
||||
// Cap and join bytes are only partly understood: (0, 0) is butt/miter in every clean sample;
|
||||
// (2, 2) ends plain lines square and brush lines round; joins import round, which never
|
||||
// looks wrong.
|
||||
let (cap, join) = match (data.get(8).copied(), data.get(9).copied(), data.get(10).copied()) {
|
||||
(Some(0), Some(0), _) => (Cap::Butt, Join::Miter),
|
||||
(Some(0), Some(_), _) => (Cap::Butt, Join::Round),
|
||||
(Some(2), Some(2), Some(3)) => (Cap::Round, Join::Round),
|
||||
(Some(2), Some(2), _) => (Cap::Square, Join::Round),
|
||||
_ => (Cap::Round, Join::Round),
|
||||
};
|
||||
let align = match s.int(ldsc, b"LDSa") {
|
||||
Some(1) => Align::Inside,
|
||||
Some(2) => Align::Outside,
|
||||
_ => Align::Center,
|
||||
};
|
||||
let dash = match s.field(lsty, b"Patn") {
|
||||
Some(Value::Array(a)) if data.get(10) == Some(&2) || !a.is_empty() => {
|
||||
let mut v: Vec<f64> = a.iter().filter_map(|x| if let Value::Float(f) = x { Some(*f) } else { None }).collect();
|
||||
while v.len() >= 2 && v.iter().rev().take(2).all(|x| *x == 0.0) {
|
||||
v.truncate(v.len() - 2);
|
||||
}
|
||||
let valid = !v.is_empty() && v.iter().all(|x| x.is_finite() && *x >= 0.0) && v.iter().any(|x| *x > 0.0);
|
||||
valid.then(|| (v.iter().map(|x| x * width).collect(), s.f64(lsty, b"Phse").unwrap_or(0.0) * width))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if s.obj(lsty, b"Brus").is_some() || s.obj(ldsc, b"LDeP").is_some() {
|
||||
r.warn("brush and pressure strokes (imported as plain strokes)");
|
||||
}
|
||||
Some(Stroke { paint, width, cap, join, miter_limit, align, dash, behind })
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
//! The indexed PNG thumbnail Affinity stores next to the archive: Affinity's own render of the
|
||||
//! document, at most 512 pixels on the long side in real files, never the document itself.
|
||||
|
||||
use crate::Error;
|
||||
use crate::container::header;
|
||||
|
||||
pub const MAX_PREVIEW_BYTES: usize = 16 << 20;
|
||||
pub const MAX_PREVIEW_DIMENSION: u32 = 4096;
|
||||
|
||||
/// Borrowed embedded thumbnail, at its own dimensions, never the native document dimensions.
|
||||
#[derive(Debug)]
|
||||
pub struct Preview<'a> {
|
||||
pub png: &'a [u8],
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
fn bytes_at(bytes: &[u8], offset: usize, length: usize) -> Result<&[u8], Error> {
|
||||
let end = offset.checked_add(length).ok_or(Error::Malformed("offset overflow"))?;
|
||||
bytes.get(offset..end).ok_or(Error::Malformed("truncated record"))
|
||||
}
|
||||
|
||||
fn le32(bytes: &[u8], offset: usize) -> Result<u32, Error> {
|
||||
let b = bytes_at(bytes, offset, 4)?;
|
||||
Ok(u32::from_le_bytes(b.try_into().map_err(|_| Error::Malformed("integer"))?))
|
||||
}
|
||||
|
||||
fn be32(bytes: &[u8], offset: usize) -> Result<u32, Error> {
|
||||
let b = bytes_at(bytes, offset, 4)?;
|
||||
Ok(u32::from_be_bytes(b.try_into().map_err(|_| Error::Malformed("PNG integer"))?))
|
||||
}
|
||||
|
||||
/// Read only the indexed `Thmb` record (identical in container versions 8 to 12), checking every PNG chunk CRC. Compressed
|
||||
/// pixels must still be validated by the consuming image decoder, with allocation limits.
|
||||
pub fn preview(bytes: &[u8]) -> Result<Preview<'_>, Error> {
|
||||
let h = header(bytes)?;
|
||||
if h.thumbnail_offset == 0 {
|
||||
return Err(Error::Unsupported("no embedded preview"));
|
||||
}
|
||||
let offset = usize::try_from(h.thumbnail_offset).map_err(|_| Error::Malformed("thumbnail offset overflow"))?;
|
||||
if offset < 72 {
|
||||
return Err(Error::Malformed("thumbnail overlaps the file header"));
|
||||
}
|
||||
let record = bytes.get(offset..).ok_or(Error::Malformed("thumbnail offset outside file"))?;
|
||||
if bytes_at(record, 0, 8)? != b"\xff\xff\xff\xffThmb" {
|
||||
return Err(Error::Malformed("missing indexed thumbnail"));
|
||||
}
|
||||
if le32(record, 8)? != 1 || bytes_at(record, 28, 1)? != [1] {
|
||||
return Err(Error::Unsupported("thumbnail record version or encoding"));
|
||||
}
|
||||
let size = usize::try_from(le32(record, 24)?).map_err(|_| Error::Limit("encoded size"))?;
|
||||
if size > MAX_PREVIEW_BYTES {
|
||||
return Err(Error::Limit("encoded preview exceeds 16 MiB"));
|
||||
}
|
||||
if le32(record, 16)? != 29 || le32(record, 20)? != 0 || u64::from(le32(record, 12)?) != size as u64 + 13 {
|
||||
return Err(Error::Malformed("inconsistent thumbnail record lengths"));
|
||||
}
|
||||
let png = bytes_at(record, 29, size)?;
|
||||
if bytes_at(png, 0, 8)? != b"\x89PNG\r\n\x1a\n" || be32(png, 8)? != 13 || bytes_at(png, 12, 4)? != b"IHDR" {
|
||||
return Err(Error::Malformed("thumbnail is not a PNG"));
|
||||
}
|
||||
let width = be32(png, 16)?;
|
||||
let height = be32(png, 20)?;
|
||||
if width == 0 || height == 0 || width > MAX_PREVIEW_DIMENSION || height > MAX_PREVIEW_DIMENSION {
|
||||
return Err(Error::Limit("preview dimensions must be 1..4096"));
|
||||
}
|
||||
// Bound work by bytes, not a file-supplied chunk count, and require exactly one complete PNG.
|
||||
let mut pos = 8usize;
|
||||
let (mut palette, mut image_data, mut image_data_ended) = (false, false, false);
|
||||
loop {
|
||||
let length = usize::try_from(be32(png, pos)?).map_err(|_| Error::Limit("PNG chunk length"))?;
|
||||
let chunk_size = length.checked_add(12).ok_or(Error::Malformed("PNG chunk overflow"))?;
|
||||
let chunk = bytes_at(png, pos, chunk_size)?;
|
||||
let checksum_input = bytes_at(chunk, 4, chunk_size - 8)?;
|
||||
if crc32fast::hash(checksum_input) != be32(chunk, chunk_size - 4)? {
|
||||
return Err(Error::Malformed("PNG chunk checksum"));
|
||||
}
|
||||
let kind = bytes_at(chunk, 4, 4)?;
|
||||
if !kind.iter().all(u8::is_ascii_alphabetic) {
|
||||
return Err(Error::Malformed("PNG chunk type"));
|
||||
}
|
||||
match kind {
|
||||
b"IHDR" if pos == 8 => {}
|
||||
b"IHDR" => return Err(Error::Malformed("duplicate PNG header")),
|
||||
b"PLTE" => {
|
||||
if palette || image_data {
|
||||
return Err(Error::Malformed("PNG palette order"));
|
||||
}
|
||||
palette = true;
|
||||
}
|
||||
b"IDAT" => {
|
||||
if image_data_ended {
|
||||
return Err(Error::Malformed("non-contiguous PNG image data"));
|
||||
}
|
||||
image_data = true;
|
||||
}
|
||||
b"IEND" => {
|
||||
if !image_data {
|
||||
return Err(Error::Malformed("PNG has no image data"));
|
||||
}
|
||||
}
|
||||
_ if kind.first().is_some_and(u8::is_ascii_uppercase) => {
|
||||
return Err(Error::Unsupported("unknown critical PNG chunk"));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if image_data && kind != b"IDAT" {
|
||||
image_data_ended = true;
|
||||
}
|
||||
// Native sample previews are plain, single-frame PNGs. Pixel allocation limits do not
|
||||
// necessarily bound aggregate compressed metadata; leave these layouts unsupported.
|
||||
if matches!(kind, b"zTXt" | b"iTXt" | b"iCCP") {
|
||||
return Err(Error::Unsupported("compressed PNG metadata in the preview"));
|
||||
}
|
||||
if matches!(kind, b"acTL" | b"fcTL" | b"fdAT") {
|
||||
return Err(Error::Unsupported("animated PNG preview"));
|
||||
}
|
||||
pos = pos.checked_add(chunk_size).ok_or(Error::Malformed("PNG offset overflow"))?;
|
||||
if kind == b"IEND" {
|
||||
if length != 0 || pos != png.len() {
|
||||
return Err(Error::Malformed("PNG end or trailing data"));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(Preview { png, width, height })
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
//! Pixel layers (`Rstr`) and placed images (`ImgN`). A placed image keeps its original file
|
||||
//! (`DyBm.Bckg` → archive entry `c/N`), which is imported as is; other pixels are planar tiles of
|
||||
//! 256 bytes × 256 rows per channel, each its own archive entry.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::Error;
|
||||
use crate::model::{Affine, Image, Pixels, Reader};
|
||||
use crate::stream::{self, ObjId, Tag, Value};
|
||||
|
||||
/// Pixels decoded per layer (the content's bounding box, not the canvas).
|
||||
const MAX_PIXELS: u64 = 64 << 20;
|
||||
const TILE: usize = 256;
|
||||
const TILE_BYTES: usize = TILE * TILE;
|
||||
|
||||
pub(crate) fn node_image(r: &mut Reader, id: ObjId, world: Affine) -> Result<Option<Image>, Error> {
|
||||
let s = r.s;
|
||||
let Some(bitmap) = s.obj(id, b"Bitm") else {
|
||||
r.warn("pixel layers without pixels");
|
||||
return Ok(None);
|
||||
};
|
||||
if !s.is(bitmap, b"DyBm") {
|
||||
r.warn("pixel layers stored in an unknown way");
|
||||
return Ok(None);
|
||||
}
|
||||
let width = s.int(bitmap, b"BmpW").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
|
||||
let height = s.int(bitmap, b"BmpH").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
|
||||
if width == 0 || height == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
// A placed image: the original file, byte for byte.
|
||||
let has_tiles = s.field(bitmap, b"Sta1").is_some();
|
||||
if let Some(name) = s.entry(bitmap, b"Bckg").filter(|n| !n.is_empty())
|
||||
&& (s.is(id, b"ImgN") || !has_tiles)
|
||||
{
|
||||
match original(r, name) {
|
||||
Ok(bytes) => return Ok(Some(Image { width, height, pixels: Pixels::Encoded(bytes), transform: world })),
|
||||
Err(Error::Limit(e)) => return Err(Error::Limit(e)),
|
||||
Err(_) => r.warn("an embedded image file that could not be read (its cached pixels were used)"),
|
||||
}
|
||||
}
|
||||
// The content's bounding box, so a mostly empty canvas-sized layer stays small.
|
||||
let crop = s
|
||||
.field(id, b"BitR")
|
||||
.and_then(|v| if let Value::Ints(i) = v { Some(i.clone()) } else { None })
|
||||
.filter(|v| v.len() == 4 && v[0] > i64::from(i32::MIN) + 1)
|
||||
.map(|v| {
|
||||
(
|
||||
v[0].clamp(0, i64::from(width)) as u32,
|
||||
v[1].clamp(0, i64::from(height)) as u32,
|
||||
v[2].clamp(0, i64::from(width)) as u32,
|
||||
v[3].clamp(0, i64::from(height)) as u32,
|
||||
)
|
||||
})
|
||||
.unwrap_or((0, 0, width, height));
|
||||
if s.field(bitmap, b"Bckg").is_some() && has_tiles && !s.is(id, b"ImgN") {
|
||||
r.warn("edited placed images use their stored pixels");
|
||||
}
|
||||
decode(r, bitmap, crop, world)
|
||||
}
|
||||
|
||||
/// A mask layer (`MRst`): one channel of coverage as grey pixels (white shows, black hides).
|
||||
pub(crate) fn mask(r: &mut Reader, id: ObjId, world: Affine) -> Result<Option<Image>, Error> {
|
||||
let s = r.s;
|
||||
let Some(bitmap) = s.obj(id, b"Bitm") else { return Ok(None) };
|
||||
let width = s.int(bitmap, b"BmpW").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
|
||||
let height = s.int(bitmap, b"BmpH").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
|
||||
if width == 0 || height == 0 || !matches!(s.enumeration(bitmap, b"Frmt"), Some((6 | 7, _))) {
|
||||
r.warn("pixel masks in an unknown format (imported without them)");
|
||||
return Ok(None);
|
||||
}
|
||||
decode(r, bitmap, (0, 0, width, height), world)
|
||||
}
|
||||
|
||||
/// An embedded document or symbol instance (`EmbN`), as the picture of it Affinity caches.
|
||||
pub(crate) fn embedded(r: &mut Reader, id: ObjId, world: Affine) -> Result<Option<Image>, Error> {
|
||||
let s = r.s;
|
||||
let Some(cache) = s.obj(id, b"Bitm").and_then(|e| s.obj(e, b"Cach")) else { return Ok(None) };
|
||||
let Some(bbox) = s.obj(id, b"Bitm").and_then(|e| s.floats::<4>(e, b"ChBB")) else { return Ok(None) };
|
||||
let width = s.int(cache, b"BmpW").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
|
||||
let height = s.int(cache, b"BmpH").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
|
||||
if width == 0 || height == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let [x0, y0, x1, y1] = bbox;
|
||||
// The instance's origin is the centre of the embedded content (checked against the
|
||||
// thumbnails of documents with embedded documents and SVG files).
|
||||
let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
|
||||
let place = Affine([(x1 - x0) / f64::from(width), 0.0, 0.0, (y1 - y0) / f64::from(height), x0 - cx, y0 - cy]).then(world);
|
||||
decode(r, cache, (0, 0, width, height), place)
|
||||
}
|
||||
|
||||
/// Decode the tiles of a bitmap inside `crop` (pixels), placed by `world` (bitmap pixels to document).
|
||||
fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affine) -> Result<Option<Image>, Error> {
|
||||
let s = r.s;
|
||||
let (x0, y0, x1, y1) = crop;
|
||||
if x1 <= x0 || y1 <= y0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let (w, h) = (x1 - x0, y1 - y0);
|
||||
if u64::from(w) * u64::from(h) > MAX_PIXELS {
|
||||
r.warn("pixel layers larger than 64 megapixels");
|
||||
return Ok(None);
|
||||
}
|
||||
let format = s.enumeration(bitmap, b"Frmt").map(|(f, _)| f);
|
||||
let (channels, bps) = match format {
|
||||
Some(0) => (4, 1),
|
||||
Some(1) => (4, 2),
|
||||
Some(6) => (1, 1),
|
||||
Some(7) => (1, 2),
|
||||
Some(4) => {
|
||||
r.warn("CMYK pixel layers (converted to RGB without the document's colour profile)");
|
||||
(5, 1)
|
||||
}
|
||||
_ => {
|
||||
r.warn("pixel layers in grey, Lab or 32-bit formats");
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
let mut planes = Vec::with_capacity(channels);
|
||||
let mut cache = HashMap::new();
|
||||
for c in 1..=channels {
|
||||
planes.push(plane(r, bitmap, c, bps, (x0, y0, w, h), &mut cache)?);
|
||||
}
|
||||
let n = (w as usize) * (h as usize);
|
||||
let mut rgba = vec![0u8; n * 4];
|
||||
let sample = |p: &Vec<u8>, i: usize| -> f64 {
|
||||
if bps == 2 {
|
||||
f64::from(u16::from_le_bytes([p.get(2 * i).copied().unwrap_or(0), p.get(2 * i + 1).copied().unwrap_or(0)])) / 65535.0
|
||||
} else {
|
||||
f64::from(p.get(i).copied().unwrap_or(0)) / 255.0
|
||||
}
|
||||
};
|
||||
for (i, px) in rgba.as_chunks_mut::<4>().0.iter_mut().enumerate() {
|
||||
let v: Vec<f64> = planes.iter().map(|p| sample(p, i)).collect();
|
||||
let (r2, g, b, a) = if channels == 1 {
|
||||
(v[0], v[0], v[0], 1.0)
|
||||
} else if channels == 5 {
|
||||
let k = 1.0 - v[3];
|
||||
((1.0 - v[0]) * k, (1.0 - v[1]) * k, (1.0 - v[2]) * k, v[4])
|
||||
} else {
|
||||
(v[0], v[1], v[2], v[3])
|
||||
};
|
||||
px.copy_from_slice(&[r2, g, b, a].map(|c| (c.clamp(0.0, 1.0) * 255.0).round() as u8));
|
||||
}
|
||||
let transform = Affine([1.0, 0.0, 0.0, 1.0, f64::from(x0), f64::from(y0)]).then(world);
|
||||
Ok(Some(Image { width: w, height: h, pixels: Pixels::Rgba8(rgba), transform }))
|
||||
}
|
||||
|
||||
/// The original image file: entry `c/N` is a stream whose root (`Blck`) holds it as `Data`.
|
||||
fn original(r: &mut Reader, name: &str) -> Result<Vec<u8>, Error> {
|
||||
let bytes = r.archive.read(name)?;
|
||||
let s = stream::parse(&bytes)?;
|
||||
let root = s.object(s.root).ok_or(Error::Malformed("embedded image"))?;
|
||||
match root.get(Tag::of(b"Data")) {
|
||||
Some(Value::Blob(b)) if !b.is_empty() => Ok(b.clone()),
|
||||
_ => Err(Error::Malformed("embedded image without data")),
|
||||
}
|
||||
}
|
||||
|
||||
fn tag(prefix: &[u8; 3], c: usize) -> [u8; 4] {
|
||||
[prefix[0], prefix[1], prefix[2], b'0' + c as u8]
|
||||
}
|
||||
|
||||
/// One channel of level 0, cropped to (x0, y0, w, h) pixels; `bps` bytes per sample.
|
||||
fn plane(
|
||||
r: &mut Reader,
|
||||
bitmap: ObjId,
|
||||
c: usize,
|
||||
bps: usize,
|
||||
(x0, y0, w, h): (u32, u32, u32, u32),
|
||||
cache: &mut HashMap<String, Vec<u8>>,
|
||||
) -> Result<Vec<u8>, Error> {
|
||||
let s = r.s;
|
||||
let width = s.int(bitmap, b"BmpW").and_then(|v| usize::try_from(v).ok()).unwrap_or(0);
|
||||
let height = s.int(bitmap, b"BmpH").and_then(|v| usize::try_from(v).ok()).unwrap_or(0);
|
||||
let tw = s.int(bitmap, &tag(b"TWi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| (width * bps).div_ceil(TILE));
|
||||
let th = s.int(bitmap, &tag(b"THi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| height.div_ceil(TILE));
|
||||
let states: Vec<u8> = match s.field(bitmap, &tag(b"Sta", c)) {
|
||||
Some(Value::Array(a)) => a.iter().map(|v| if let Value::UInt(u) = v { u8::try_from(*u).unwrap_or(255) } else { 255 }).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let tiles = s.objs(bitmap, &tag(b"Idx", c));
|
||||
let row = (w as usize) * bps;
|
||||
let mut out = vec![0u8; row * h as usize];
|
||||
let (bx0, bx1) = (x0 as usize * bps, (x0 + w) as usize * bps);
|
||||
let (py0, py1) = (y0 as usize, (y0 + h) as usize);
|
||||
let mut next = 0usize;
|
||||
for (t, state) in states.iter().enumerate().take(tw.saturating_mul(th)) {
|
||||
let (ty, tx) = (t / tw.max(1), t % tw.max(1));
|
||||
let fill = match state {
|
||||
0 | 1 => None,
|
||||
2 => Some(0xFF),
|
||||
4 => {
|
||||
let Some(blck) = tiles.get(next).copied() else { return Err(Error::Malformed("missing pixel tile")) };
|
||||
next += 1;
|
||||
let (ox, oy) = (tx * TILE, ty * TILE);
|
||||
if ox >= bx1 || ox + TILE <= bx0 || oy >= py1 || oy + TILE <= py0 {
|
||||
continue;
|
||||
}
|
||||
let name = s.entry(blck, b"Data").ok_or(Error::Malformed("pixel tile without data"))?.to_string();
|
||||
if !cache.contains_key(&name) {
|
||||
let mut data = r.archive.read(&name)?;
|
||||
// Older files wrap a tile in a one-field stream: header, then a 64 KiB blob.
|
||||
if data.len() == TILE_BYTES + 22 && data.starts_with(b"\x00\xffKS") {
|
||||
data = data.get(21..21 + TILE_BYTES).ok_or(Error::Malformed("pixel tile"))?.to_vec();
|
||||
}
|
||||
if data.len() != TILE_BYTES {
|
||||
return Err(Error::Malformed("pixel tile size"));
|
||||
}
|
||||
cache.insert(name.clone(), data);
|
||||
}
|
||||
let tile = cache.get(&name).ok_or(Error::Malformed("pixel tile"))?;
|
||||
copy(&mut out, row, (bx0, py0, bx1, py1), (ox, oy), |x, y| tile.get(y * TILE + x).copied().unwrap_or(0));
|
||||
continue;
|
||||
}
|
||||
3 => {
|
||||
r.warn("32-bit pixel layers");
|
||||
None
|
||||
}
|
||||
5 => {
|
||||
r.warn("pixel layers drawn from an embedded image (left empty)");
|
||||
None
|
||||
}
|
||||
_ => return Err(Error::Malformed("unknown pixel tile state")),
|
||||
};
|
||||
if let Some(v) = fill {
|
||||
copy(&mut out, row, (bx0, py0, bx1, py1), (tx * TILE, ty * TILE), |_, _| v);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Copy the overlap of a tile at byte column `ox`, row `oy` into the cropped plane.
|
||||
fn copy(out: &mut [u8], row: usize, (bx0, py0, bx1, py1): (usize, usize, usize, usize), (ox, oy): (usize, usize), at: impl Fn(usize, usize) -> u8) {
|
||||
for y in oy.max(py0)..(oy + TILE).min(py1) {
|
||||
for x in ox.max(bx0)..(ox + TILE).min(bx1) {
|
||||
if let Some(b) = out.get_mut((y - py0) * row + (x - bx0)) {
|
||||
*b = at(x - ox, y - oy);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
//! Parametric shapes: Affinity stores the shape's parameters and its box, never the outline.
|
||||
//! Formulas were fitted to Affinity's own renders (the document thumbnails) of public files.
|
||||
|
||||
use std::f64::consts::{FRAC_PI_2, PI, TAU};
|
||||
|
||||
use crate::model::{Affine, Path, Point, Reader, SubPath};
|
||||
use crate::stream::{ObjId, Tag, Value};
|
||||
|
||||
/// Handle length of a quarter circle of radius 1.
|
||||
const KAPPA: f64 = 0.552_284_749_830_793_4;
|
||||
/// Vertices of a polygon or star.
|
||||
const MAX_SIDES: i64 = 10_000;
|
||||
|
||||
/// The last value of a tag: a shape changed from one kind to another keeps a stale first block.
|
||||
fn last<'a>(r: &'a Reader, id: ObjId, tag: &[u8; 4]) -> Option<&'a Value> {
|
||||
let t = Tag::of(tag);
|
||||
r.s.object(id)?.fields.iter().rev().find(|(k, _)| *k == t).map(|(_, v)| v)
|
||||
}
|
||||
|
||||
fn num(r: &Reader, id: ObjId, tag: &[u8; 4]) -> Option<f64> {
|
||||
match last(r, id, tag)? {
|
||||
Value::Float(f) if f.is_finite() => Some(*f),
|
||||
Value::UInt(u) => Some(*u as f64),
|
||||
Value::Int(i) => Some(*i as f64),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn sides(r: &Reader, id: ObjId, tag: &[u8; 4], default: f64) -> usize {
|
||||
let n = num(r, id, tag).unwrap_or(default).round();
|
||||
n.clamp(3.0, MAX_SIDES as f64) as usize
|
||||
}
|
||||
|
||||
fn pt(x: f64, y: f64) -> Point {
|
||||
Point { x, y }
|
||||
}
|
||||
|
||||
fn polygon(pts: impl IntoIterator<Item = Point>) -> Option<SubPath> {
|
||||
let mut it = pts.into_iter();
|
||||
let start = it.next()?;
|
||||
let segments = it.map(|p| [p, p, p]).collect();
|
||||
Some(SubPath { start, segments, closed: true })
|
||||
}
|
||||
|
||||
/// Outline of `shape` (a `Shpe` object) in the node box `b` = (x0, y0, x1, y1).
|
||||
pub(crate) fn shape(r: &mut Reader, shape: ObjId, b: [f64; 4], world: Affine) -> Option<Path> {
|
||||
let [x0, y0, x1, y1] = b;
|
||||
let (w, h) = (x1 - x0, y1 - y0);
|
||||
let (cx, cy, rx, ry) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0, w / 2.0, h / 2.0);
|
||||
let on_ellipse = |a: f64, f: f64| pt(cx + f * rx * a.cos(), cy + f * ry * a.sin());
|
||||
let class = r.s.class(shape)?;
|
||||
let sub = match &class.0.to_be_bytes() {
|
||||
b"ShNR" | b"ShRR" => {
|
||||
let rounded = matches!(last(r, shape, b"CTyp"), Some(Value::Array(a)) if !a.is_empty());
|
||||
let radius = match last(r, shape, b"ShCR") {
|
||||
Some(Value::Floats(v)) => v.first().copied().filter(|f| f.is_finite() && *f > 0.0),
|
||||
_ => None,
|
||||
};
|
||||
if let (true, Some(f)) = (rounded, radius) {
|
||||
if let Some(Value::Array(types)) = last(r, shape, b"CTyp")
|
||||
&& types.iter().any(|t| !matches!(t, Value::Enum { id: 0 | 4, .. }))
|
||||
{
|
||||
r.warn("rectangles with straight, concave or other corner types (imported with round corners)");
|
||||
}
|
||||
let (crx, cry) = if r.s.bool(shape, b"AbSz") == Some(true) {
|
||||
// An absolute radius is in document pixels, whatever the node's scale.
|
||||
let [a, b2, c, d, ..] = world.0;
|
||||
let (sx, sy) = ((a * a + b2 * b2).sqrt(), (c * c + d * d).sqrt());
|
||||
if sx > 0.0 && sy > 0.0 { (f / sx, f / sy) } else { (0.0, 0.0) }
|
||||
} else {
|
||||
(f * w.min(h), f * w.min(h))
|
||||
};
|
||||
rounded_rect(b, crx.min(w / 2.0), cry.min(h / 2.0))
|
||||
} else {
|
||||
polygon([pt(x0, y0), pt(x1, y0), pt(x1, y1), pt(x0, y1)])?
|
||||
}
|
||||
}
|
||||
b"ShpE" => ellipse(cx, cy, rx, ry),
|
||||
b"ShPy" => {
|
||||
let n = sides(r, shape, b"Side", 5.0);
|
||||
let a = |i: usize| -FRAC_PI_2 + TAU * i as f64 / n as f64;
|
||||
if r.s.object(shape).is_some_and(|_| matches!(last(r, shape, b"Smth"), Some(Value::Bool(true)))) {
|
||||
// Smoothed: tangent handles of length curv·4/3·tan(π/2n) along the inscribed ellipse.
|
||||
let k = num(r, shape, b"Curv").unwrap_or(0.0) * 4.0 / 3.0 * (PI / (2.0 * n as f64)).tan();
|
||||
let v = |i: usize| on_ellipse(a(i), 1.0);
|
||||
let t = |i: usize| pt(-rx * a(i).sin(), ry * a(i).cos());
|
||||
let segments = (0..n)
|
||||
.map(|i| {
|
||||
let (p, q, tp, tq) = (v(i), v(i + 1), t(i), t(i + 1));
|
||||
[pt(p.x + k * tp.x, p.y + k * tp.y), pt(q.x - k * tq.x, q.y - k * tq.y), q]
|
||||
})
|
||||
.collect();
|
||||
SubPath { start: v(0), segments, closed: true }
|
||||
} else {
|
||||
polygon((0..n).map(|i| on_ellipse(a(i), 1.0)))?
|
||||
}
|
||||
}
|
||||
b"ShSt" => {
|
||||
let n = sides(r, shape, b"Pnts", 5.0);
|
||||
let q = num(r, shape, b"IRad").unwrap_or(0.382).clamp(0.0, 1.0);
|
||||
if [b"CrcI", b"CrcO", b"CrvL", b"CrvR"].iter().any(|t| num(r, shape, t).is_some_and(|v| v.abs() > 1e-9)) {
|
||||
r.warn("stars with rounded or curved points (imported with sharp points)");
|
||||
}
|
||||
polygon((0..2 * n).map(|i| on_ellipse(-FRAC_PI_2 + PI * i as f64 / n as f64, if i % 2 == 0 { 1.0 } else { q })))?
|
||||
}
|
||||
b"ShSS" => {
|
||||
let n = sides(r, shape, b"Side", 5.0);
|
||||
let ri = num(r, shape, b"COut").unwrap_or(0.5).clamp(0.0, 1.0);
|
||||
let step = PI / n as f64;
|
||||
let (l, hw, inner) = (step.cos(), ri * step.sin(), ri * step.cos());
|
||||
polygon((0..n).flat_map(|k| {
|
||||
let phi = -FRAC_PI_2 + step + TAU * k as f64 / n as f64;
|
||||
let (c, s) = (phi.cos(), phi.sin());
|
||||
[(inner, -hw), (l, -hw), (l, hw), (inner, hw)].map(|(u, v)| pt(cx + rx * (u * c - v * s), cy + ry * (u * s + v * c)))
|
||||
}))?
|
||||
}
|
||||
b"ShPi" => {
|
||||
let start = num(r, shape, b"AngS").unwrap_or(0.0);
|
||||
let mut end = num(r, shape, b"AngE").unwrap_or(TAU);
|
||||
while end < start {
|
||||
end += TAU;
|
||||
}
|
||||
let inner = num(r, shape, b"IRad").unwrap_or(0.0).clamp(0.0, 1.0);
|
||||
pie(cx, cy, rx, ry, start, end.min(start + TAU), inner)
|
||||
}
|
||||
b"ShpT" => {
|
||||
let p = num(r, shape, b"Pos ").unwrap_or(0.5);
|
||||
polygon([pt(x0 + p * w, y0), pt(x1, y1), pt(x0, y1)])?
|
||||
}
|
||||
b"ShTz" => {
|
||||
let (l, rr) = (num(r, shape, b"PosL").unwrap_or(0.25), num(r, shape, b"PosR").unwrap_or(0.75));
|
||||
polygon([pt(x0 + l * w, y0), pt(x0 + rr * w, y0), pt(x1, y1), pt(x0, y1)])?
|
||||
}
|
||||
_ => {
|
||||
r.warn("cloud, heart, cog, callout, arrow, tear and other special shapes (imported as their bounding ellipse)");
|
||||
ellipse(cx, cy, rx, ry)
|
||||
}
|
||||
};
|
||||
Some(Path { subpaths: vec![sub] })
|
||||
}
|
||||
|
||||
pub(crate) fn rectangle(x0: f64, y0: f64, x1: f64, y1: f64) -> SubPath {
|
||||
SubPath { start: pt(x0, y0), segments: [pt(x1, y0), pt(x1, y1), pt(x0, y1)].map(|p| [p, p, p]).to_vec(), closed: true }
|
||||
}
|
||||
|
||||
fn ellipse(cx: f64, cy: f64, rx: f64, ry: f64) -> SubPath {
|
||||
let (kx, ky) = (KAPPA * rx, KAPPA * ry);
|
||||
SubPath {
|
||||
start: pt(cx + rx, cy),
|
||||
segments: vec![
|
||||
[pt(cx + rx, cy + ky), pt(cx + kx, cy + ry), pt(cx, cy + ry)],
|
||||
[pt(cx - kx, cy + ry), pt(cx - rx, cy + ky), pt(cx - rx, cy)],
|
||||
[pt(cx - rx, cy - ky), pt(cx - kx, cy - ry), pt(cx, cy - ry)],
|
||||
[pt(cx + kx, cy - ry), pt(cx + rx, cy - ky), pt(cx + rx, cy)],
|
||||
],
|
||||
closed: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn rounded_rect([x0, y0, x1, y1]: [f64; 4], rx: f64, ry: f64) -> SubPath {
|
||||
let (kx, ky) = ((1.0 - KAPPA) * rx, (1.0 - KAPPA) * ry);
|
||||
SubPath {
|
||||
start: pt(x0 + rx, y0),
|
||||
segments: vec![
|
||||
[pt(x1 - rx, y0), pt(x1 - rx, y0), pt(x1 - rx, y0)],
|
||||
[pt(x1 - kx, y0), pt(x1, y0 + ky), pt(x1, y0 + ry)],
|
||||
[pt(x1, y1 - ry), pt(x1, y1 - ry), pt(x1, y1 - ry)],
|
||||
[pt(x1, y1 - ky), pt(x1 - kx, y1), pt(x1 - rx, y1)],
|
||||
[pt(x0 + rx, y1), pt(x0 + rx, y1), pt(x0 + rx, y1)],
|
||||
[pt(x0 + kx, y1), pt(x0, y1 - ky), pt(x0, y1 - ry)],
|
||||
[pt(x0, y0 + ry), pt(x0, y0 + ry), pt(x0, y0 + ry)],
|
||||
[pt(x0, y0 + ky), pt(x0 + kx, y0), pt(x0 + rx, y0)],
|
||||
],
|
||||
closed: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Elliptical arc from angle `a0` to `a1` (radians, measured y-up as Affinity's pie angles are),
|
||||
/// as cubic segments of at most a quarter turn.
|
||||
fn arc(cx: f64, cy: f64, rx: f64, ry: f64, a0: f64, a1: f64, out: &mut Vec<[Point; 3]>) {
|
||||
let n = ((a1 - a0).abs() / FRAC_PI_2).ceil().clamp(1.0, 8.0) as usize;
|
||||
let step = (a1 - a0) / n as f64;
|
||||
let k = 4.0 / 3.0 * (step / 4.0).tan();
|
||||
let p = |a: f64| pt(cx + rx * a.cos(), cy - ry * a.sin());
|
||||
let d = |a: f64| pt(-rx * a.sin(), -ry * a.cos());
|
||||
for i in 0..n {
|
||||
let (s, e) = (a0 + step * i as f64, a0 + step * (i + 1) as f64);
|
||||
let (ps, pe, ds, de) = (p(s), p(e), d(s), d(e));
|
||||
out.push([pt(ps.x + k * ds.x, ps.y + k * ds.y), pt(pe.x - k * de.x, pe.y - k * de.y), pe]);
|
||||
}
|
||||
}
|
||||
|
||||
fn pie(cx: f64, cy: f64, rx: f64, ry: f64, a0: f64, a1: f64, inner: f64) -> SubPath {
|
||||
let start = pt(cx + rx * a0.cos(), cy - ry * a0.sin());
|
||||
let mut segments = Vec::new();
|
||||
arc(cx, cy, rx, ry, a0, a1, &mut segments);
|
||||
if inner > 0.0 {
|
||||
let p = pt(cx + inner * rx * a1.cos(), cy - inner * ry * a1.sin());
|
||||
segments.push([p, p, p]);
|
||||
arc(cx, cy, inner * rx, inner * ry, a1, a0, &mut segments);
|
||||
} else {
|
||||
let c = pt(cx, cy);
|
||||
segments.push([c, c, c]);
|
||||
}
|
||||
SubPath { start, segments, closed: true }
|
||||
}
|
||||
|
||||
/// A polyline with circular corners of the given radii (Affinity's live corners on curves).
|
||||
pub(crate) fn fillet(pts: &[(Point, f64)], closed: bool) -> SubPath {
|
||||
let n = pts.len();
|
||||
let get = |i: isize| -> Option<Point> {
|
||||
if closed { pts.get(i.rem_euclid(n as isize) as usize).map(|p| p.0) } else { usize::try_from(i).ok().and_then(|i| pts.get(i)).map(|p| p.0) }
|
||||
};
|
||||
let mut corners: Vec<(Point, Point, Point, Point)> = Vec::with_capacity(n);
|
||||
for (i, (p, radius)) in pts.iter().enumerate() {
|
||||
let (prev, next) = (get(i as isize - 1), get(i as isize + 1));
|
||||
let (Some(a), Some(b)) = (prev, next) else {
|
||||
corners.push((*p, *p, *p, *p));
|
||||
continue;
|
||||
};
|
||||
let (u1, l1) = unit(*p, a);
|
||||
let (u2, l2) = unit(*p, b);
|
||||
let cos = (u1.x * u2.x + u1.y * u2.y).clamp(-1.0, 1.0);
|
||||
let phi = cos.acos();
|
||||
if *radius <= 0.0 || phi < 1e-6 || (PI - phi) < 1e-6 {
|
||||
corners.push((*p, *p, *p, *p));
|
||||
continue;
|
||||
}
|
||||
let t = (radius / (phi / 2.0).tan()).min(l1 / 2.0).min(l2 / 2.0);
|
||||
let r = t * (phi / 2.0).tan();
|
||||
let k = 4.0 / 3.0 * ((PI - phi) / 4.0).tan() * r;
|
||||
let s = pt(p.x + u1.x * t, p.y + u1.y * t);
|
||||
let e = pt(p.x + u2.x * t, p.y + u2.y * t);
|
||||
corners.push((s, pt(s.x - u1.x * k, s.y - u1.y * k), pt(e.x - u2.x * k, e.y - u2.y * k), e));
|
||||
}
|
||||
let Some(first) = corners.first().copied() else { return SubPath { start: pt(0.0, 0.0), segments: vec![], closed } };
|
||||
let mut segments = Vec::with_capacity(2 * n);
|
||||
let mut cur = first.0;
|
||||
let start = cur;
|
||||
for (i, (s, c1, c2, e)) in corners.iter().enumerate() {
|
||||
if i > 0 {
|
||||
segments.push([cur, *s, *s]);
|
||||
}
|
||||
if s != e {
|
||||
segments.push([*c1, *c2, *e]);
|
||||
}
|
||||
cur = *e;
|
||||
}
|
||||
if closed && cur != start {
|
||||
segments.push([cur, start, start]);
|
||||
}
|
||||
SubPath { start, segments, closed }
|
||||
}
|
||||
|
||||
fn unit(from: Point, to: Point) -> (Point, f64) {
|
||||
let (dx, dy) = (to.x - from.x, to.y - from.y);
|
||||
let l = (dx * dx + dy * dy).sqrt();
|
||||
if l > 0.0 { (pt(dx / l, dy / l), l) } else { (pt(0.0, 0.0), 0.0) }
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
//! The tagged object stream inside `doc.dat`: a self-describing tree of objects whose fields carry
|
||||
//! a type code and a four-character tag. Parsing needs no schema; unknown fields are kept, so the
|
||||
//! document mapping can name what it could not import.
|
||||
|
||||
use crate::Error;
|
||||
use crate::container::Cursor;
|
||||
|
||||
/// A four-character tag in its readable order (`Desc`, `Chld`).
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct Tag(pub u32);
|
||||
|
||||
impl Tag {
|
||||
pub const fn of(s: &[u8; 4]) -> Self {
|
||||
Self(u32::from_be_bytes(*s))
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Tag {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", String::from_utf8_lossy(&self.0.to_be_bytes()))
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Tag {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
std::fmt::Debug::fmt(self, f)
|
||||
}
|
||||
}
|
||||
|
||||
/// Index of an object in [`Stream::objects`].
|
||||
pub type ObjId = usize;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Value {
|
||||
Int(i64),
|
||||
UInt(u64),
|
||||
Float(f64),
|
||||
Bool(bool),
|
||||
Enum {
|
||||
id: u16,
|
||||
version: u16,
|
||||
},
|
||||
Str(String),
|
||||
/// Fixed-length vectors of i32/f32/f64 (points, rectangles, transforms).
|
||||
Ints(Vec<i64>),
|
||||
Floats(Vec<f64>),
|
||||
/// Fixed-size record (curve nodes, ids) or fixed-size struct (colour components).
|
||||
Bytes(Vec<u8>),
|
||||
Blob(Vec<u8>),
|
||||
/// Name of another archive entry (raster tiles, embedded files).
|
||||
Entry(String),
|
||||
Flags(u64),
|
||||
/// `None` is a null object.
|
||||
Obj(Option<ObjId>),
|
||||
Array(Vec<Value>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Kind {
|
||||
Root,
|
||||
/// Defined once and referenced by id elsewhere.
|
||||
Shared,
|
||||
Inline,
|
||||
/// Untagged fields identified by position (curve data).
|
||||
Positional,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Object {
|
||||
pub class: Tag,
|
||||
pub kind: Kind,
|
||||
/// Class chain, most derived first (`ShpN`, `VNod`, `Node`).
|
||||
pub chain: Vec<Tag>,
|
||||
/// Fields in stream order; positional fields have tag 0.
|
||||
pub fields: Vec<(Tag, Value)>,
|
||||
}
|
||||
|
||||
impl Object {
|
||||
pub fn get(&self, tag: Tag) -> Option<&Value> {
|
||||
self.fields.iter().find(|(t, _)| *t == tag).map(|(_, v)| v)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Stream {
|
||||
pub root: ObjId,
|
||||
pub objects: Vec<Object>,
|
||||
}
|
||||
|
||||
/// Deepest object nesting accepted; real documents stay well below (each layer level adds three).
|
||||
pub const MAX_DEPTH: usize = 384;
|
||||
|
||||
const MAX_OBJECTS: usize = 8_000_000;
|
||||
|
||||
/// Most values (fields and array elements) one stream may decode. A value takes about 40 bytes
|
||||
/// in memory however few it took in the file (a bool array packs eight in a byte), so the size
|
||||
/// of the input alone doesn't bound what parsing allocates: a small compressed entry could
|
||||
/// otherwise ask for tens of gigabytes. Real documents stay far below.
|
||||
pub const MAX_VALUES: usize = 1 << 24;
|
||||
|
||||
struct Parser<'a> {
|
||||
c: Cursor<'a>,
|
||||
objects: Vec<Object>,
|
||||
/// Shared ids, defined at first use and referenced backwards.
|
||||
shared: std::collections::HashMap<u32, ObjId>,
|
||||
depth: usize,
|
||||
/// Values decoded so far, against [`MAX_VALUES`].
|
||||
values: usize,
|
||||
}
|
||||
|
||||
/// Parse a stream (`00 FF 4B 53` header). Bytes after the root's terminator are ignored.
|
||||
pub fn parse(bytes: &[u8]) -> Result<Stream, Error> {
|
||||
let mut c = Cursor::new(bytes, 0);
|
||||
if c.take(4)? != b"\x00\xffKS" {
|
||||
return Err(Error::Malformed("document stream signature"));
|
||||
}
|
||||
let version = c.u16()?;
|
||||
if version > 2 {
|
||||
return Err(Error::Unsupported("document stream version"));
|
||||
}
|
||||
let class = Tag(c.u32()?);
|
||||
c.u16()?;
|
||||
if version == 2 {
|
||||
c.u32()?;
|
||||
}
|
||||
let mut p = Parser { c, objects: Vec::new(), shared: std::collections::HashMap::new(), depth: 0, values: 0 };
|
||||
let root = p.push(Object { class, kind: Kind::Root, chain: vec![class], fields: Vec::new() })?;
|
||||
let fields = p.fields(true, class)?;
|
||||
if let Some(o) = p.objects.get_mut(root) {
|
||||
o.fields = fields;
|
||||
}
|
||||
Ok(Stream { root, objects: p.objects })
|
||||
}
|
||||
|
||||
impl Parser<'_> {
|
||||
fn push(&mut self, o: Object) -> Result<ObjId, Error> {
|
||||
if self.objects.len() >= MAX_OBJECTS {
|
||||
return Err(Error::Limit("too many objects"));
|
||||
}
|
||||
self.objects.push(o);
|
||||
Ok(self.objects.len() - 1)
|
||||
}
|
||||
|
||||
/// Counts `n` more values against [`MAX_VALUES`], before anything is allocated for them.
|
||||
fn spend(&mut self, n: usize) -> Result<(), Error> {
|
||||
self.values = self.values.checked_add(n).filter(|&v| v <= MAX_VALUES).ok_or(Error::Limit("too many values in the document stream"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// An empty array with room for `n` values, paid for first; an allocation that still fails
|
||||
/// is an error, not an abort.
|
||||
fn vec<T>(&mut self, n: usize) -> Result<Vec<T>, Error> {
|
||||
self.spend(n)?;
|
||||
let mut v = Vec::new();
|
||||
v.try_reserve_exact(n).map_err(|_| Error::Limit("not enough memory for the document stream"))?;
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// Element count of an array: each element takes at least `min` bytes, so a count the
|
||||
/// remaining input cannot hold is rejected before anything is allocated.
|
||||
fn count(&mut self, min: usize) -> Result<usize, Error> {
|
||||
let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("array length"))?;
|
||||
if n.checked_mul(min.max(1)).is_none_or(|bytes| bytes > self.c.remaining()) {
|
||||
return Err(Error::Malformed("array longer than its data"));
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
fn fields(&mut self, tagged: bool, class: Tag) -> Result<Vec<(Tag, Value)>, Error> {
|
||||
self.depth += 1;
|
||||
if self.depth > MAX_DEPTH {
|
||||
return Err(Error::Limit("objects nested too deeply"));
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
loop {
|
||||
let t = self.c.u8()?;
|
||||
let code = t & 0x7F;
|
||||
if code == 0 {
|
||||
break;
|
||||
}
|
||||
self.spend(1)?;
|
||||
let tag = if tagged { Tag(self.c.u32()?) } else { Tag(0) };
|
||||
let v = if t & 0x80 != 0 { self.array(code, class)? } else { self.scalar(code, tagged, class)? };
|
||||
out.push((tag, v));
|
||||
}
|
||||
self.depth -= 1;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn string(&mut self) -> Result<String, Error> {
|
||||
let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("string length"))?;
|
||||
Ok(String::from_utf8_lossy(self.c.take(n)?).into_owned())
|
||||
}
|
||||
|
||||
fn floats(&mut self, n: usize, wide: bool) -> Result<Value, Error> {
|
||||
let mut v = Vec::with_capacity(n);
|
||||
for _ in 0..n {
|
||||
v.push(if wide { f64::from_le_bytes(self.c.array()?) } else { f64::from(f32::from_le_bytes(self.c.array()?)) });
|
||||
}
|
||||
Ok(Value::Floats(v))
|
||||
}
|
||||
|
||||
fn scalar(&mut self, code: u8, tagged: bool, class: Tag) -> Result<Value, Error> {
|
||||
let c = &mut self.c;
|
||||
Ok(match code {
|
||||
0x01 => Value::UInt(c.u8()?.into()),
|
||||
0x02 => Value::UInt(c.u16()?.into()),
|
||||
0x03 | 0x2f | 0x34 => Value::UInt(c.u32()?.into()),
|
||||
0x04 => Value::UInt(c.u64()?),
|
||||
0x05 => Value::Int(i8::from_le_bytes(c.array()?).into()),
|
||||
0x06 => Value::Int(i16::from_le_bytes(c.array()?).into()),
|
||||
0x07 => Value::Int(i32::from_le_bytes(c.array()?).into()),
|
||||
0x08 => Value::Int(i64::from_le_bytes(c.array()?)),
|
||||
0x09 => Value::Float(f32::from_le_bytes(c.array()?).into()),
|
||||
0x0a => Value::Float(f64::from_le_bytes(c.array()?)),
|
||||
0x15..=0x19 => {
|
||||
let n = usize::from(code - 0x13);
|
||||
let mut v = Vec::with_capacity(n);
|
||||
for _ in 0..n {
|
||||
v.push(i32::from_le_bytes(c.array()?).into());
|
||||
}
|
||||
Value::Ints(v)
|
||||
}
|
||||
0x1f..=0x23 => return self.floats(usize::from(code - 0x1d), false),
|
||||
0x24..=0x28 => return self.floats(usize::from(code - 0x22), true),
|
||||
0x29 => Value::Bool(c.u8()? != 0),
|
||||
0x2a => Value::Enum { id: c.u16()?, version: c.u16()? },
|
||||
0x2b | 0x2e => Value::Str(self.string()?),
|
||||
0x2c => {
|
||||
let n = usize::from(c.u16()?);
|
||||
Value::Bytes(c.take(n)?.to_vec())
|
||||
}
|
||||
0x35..=0x74 => Value::Bytes(c.take(usize::from(code - 0x34))?.to_vec()),
|
||||
0x2d => {
|
||||
let n = usize::try_from(c.u32()?).map_err(|_| Error::Malformed("blob length"))?;
|
||||
Value::Blob(c.take(n)?.to_vec())
|
||||
}
|
||||
0x33 => {
|
||||
c.u32()?;
|
||||
let n = usize::try_from(c.u32()?).map_err(|_| Error::Malformed("entry name length"))?;
|
||||
Value::Entry(String::from_utf8_lossy(c.take(n)?).into_owned())
|
||||
}
|
||||
0x75 => {
|
||||
c.u16()?;
|
||||
let n = usize::from(c.u8()?);
|
||||
if n > 8 {
|
||||
return Err(Error::Malformed("flags wider than 64 bits"));
|
||||
}
|
||||
let mut b = [0u8; 8];
|
||||
b.get_mut(..n).ok_or(Error::Malformed("flags"))?.copy_from_slice(c.take(n)?);
|
||||
Value::Flags(u64::from_le_bytes(b))
|
||||
}
|
||||
0x30 => {
|
||||
let fields = self.fields(false, class)?;
|
||||
let id = self.push(Object { class, kind: Kind::Positional, chain: Vec::new(), fields })?;
|
||||
Value::Obj(Some(id))
|
||||
}
|
||||
0x31 | 0x32 if !tagged => return Err(Error::Malformed("object without a field tag")),
|
||||
0x31 => self.shared()?,
|
||||
0x32 => self.inline(None)?,
|
||||
_ => return Err(Error::Unsupported("unknown field type in document stream")),
|
||||
})
|
||||
}
|
||||
|
||||
fn array(&mut self, code: u8, class: Tag) -> Result<Value, Error> {
|
||||
let min = match code {
|
||||
0x01 | 0x05 | 0x29 => 1,
|
||||
0x02 | 0x06 => 2,
|
||||
0x03 | 0x07 | 0x09 | 0x2f | 0x34 => 4,
|
||||
0x04 | 0x08 | 0x0a => 8,
|
||||
0x15..=0x19 => 4 * usize::from(code - 0x13),
|
||||
0x1f..=0x23 => 4 * usize::from(code - 0x1d),
|
||||
0x24..=0x28 => 8 * usize::from(code - 0x22),
|
||||
0x35..=0x74 => usize::from(code - 0x34),
|
||||
_ => 1,
|
||||
};
|
||||
match code {
|
||||
0x29 => {
|
||||
let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("array length"))?;
|
||||
let bits = self.c.take(n.div_ceil(8))?;
|
||||
let mut v = self.vec(n)?;
|
||||
v.extend((0..n).map(|i| Value::Bool(bits.get(i / 8).is_some_and(|b| b >> (i % 8) & 1 != 0))));
|
||||
Ok(Value::Array(v))
|
||||
}
|
||||
0x2a => {
|
||||
let n = self.count(2)?;
|
||||
let version = self.c.u16()?;
|
||||
let mut v = self.vec(n)?;
|
||||
for _ in 0..n {
|
||||
v.push(Value::Enum { id: self.c.u16()?, version });
|
||||
}
|
||||
Ok(Value::Array(v))
|
||||
}
|
||||
0x2b | 0x2e => {
|
||||
self.c.u32()?;
|
||||
let n = self.count(4)?;
|
||||
let mut v = self.vec(n)?;
|
||||
for _ in 0..n {
|
||||
v.push(Value::Str(self.string()?));
|
||||
}
|
||||
Ok(Value::Array(v))
|
||||
}
|
||||
0x2c => {
|
||||
let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("array length"))?;
|
||||
let size = usize::from(self.c.u16()?);
|
||||
if n.checked_mul(size).is_none_or(|b| b > self.c.remaining()) {
|
||||
return Err(Error::Malformed("array longer than its data"));
|
||||
}
|
||||
let mut v = self.vec(n)?;
|
||||
for _ in 0..n {
|
||||
v.push(Value::Bytes(self.c.take(size)?.to_vec()));
|
||||
}
|
||||
Ok(Value::Array(v))
|
||||
}
|
||||
0x32 => {
|
||||
let n = self.count(1)?;
|
||||
let class = Tag(self.c.u32()?);
|
||||
self.c.u16()?;
|
||||
let mut v = self.vec(n)?;
|
||||
for _ in 0..n {
|
||||
v.push(self.inline(Some(class))?);
|
||||
}
|
||||
Ok(Value::Array(v))
|
||||
}
|
||||
0x2d | 0x33 | 0x75 => Err(Error::Malformed("array of an unarrayable type")),
|
||||
_ => {
|
||||
let n = self.count(min)?;
|
||||
let mut v = self.vec(n)?;
|
||||
for _ in 0..n {
|
||||
v.push(self.scalar(code, true, class)?);
|
||||
}
|
||||
Ok(Value::Array(v))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inline object; `class` is given once for a whole array of them.
|
||||
fn inline(&mut self, class: Option<Tag>) -> Result<Value, Error> {
|
||||
match self.c.u8()? {
|
||||
0 => return Ok(Value::Obj(None)),
|
||||
1 => {}
|
||||
_ => return Err(Error::Malformed("object marker")),
|
||||
}
|
||||
let class = match class {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
let c = Tag(self.c.u32()?);
|
||||
self.c.u16()?;
|
||||
c
|
||||
}
|
||||
};
|
||||
let fields = self.fields(true, class)?;
|
||||
let id = self.push(Object { class, kind: Kind::Inline, chain: vec![class], fields })?;
|
||||
Ok(Value::Obj(Some(id)))
|
||||
}
|
||||
|
||||
fn shared(&mut self) -> Result<Value, Error> {
|
||||
let flag = self.c.u8()?;
|
||||
if flag == 0 {
|
||||
return Ok(Value::Obj(None));
|
||||
}
|
||||
let key = self.c.u32()?;
|
||||
match flag {
|
||||
2 => return self.shared.get(&key).map(|id| Value::Obj(Some(*id))).ok_or(Error::Malformed("reference to an undefined object")),
|
||||
1 => {}
|
||||
_ => return Err(Error::Malformed("object marker")),
|
||||
}
|
||||
if self.shared.contains_key(&key) {
|
||||
return Err(Error::Malformed("object defined twice"));
|
||||
}
|
||||
// Register before the fields: an object's own fields may refer back to it.
|
||||
let id = self.push(Object { class: Tag(0), kind: Kind::Shared, chain: Vec::new(), fields: Vec::new() })?;
|
||||
self.shared.insert(key, id);
|
||||
let mut chain = Vec::new();
|
||||
let mut fields = Vec::new();
|
||||
loop {
|
||||
match self.c.u8()? {
|
||||
0 => {
|
||||
let tag = Tag(self.c.u32()?);
|
||||
self.c.u16()?;
|
||||
chain.push(tag);
|
||||
fields.extend(self.fields(true, tag)?);
|
||||
}
|
||||
1 => {
|
||||
chain.push(Tag(self.c.u32()?));
|
||||
break;
|
||||
}
|
||||
2 => break,
|
||||
_ => return Err(Error::Malformed("object class chain")),
|
||||
}
|
||||
if chain.len() > 64 {
|
||||
return Err(Error::Limit("object class chain too long"));
|
||||
}
|
||||
}
|
||||
let class = chain.first().copied().unwrap_or(Tag(0));
|
||||
fields.extend(self.fields(true, class)?);
|
||||
if let Some(o) = self.objects.get_mut(id) {
|
||||
*o = Object { class, kind: Kind::Shared, chain, fields };
|
||||
}
|
||||
Ok(Value::Obj(Some(id)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
//! Synthetic containers for tests and fuzz seeds (cargo feature `synth`).
|
||||
//!
|
||||
//! These builders write the layout the reader accepts, so tests can describe documents in a few
|
||||
//! lines. They are **not** an Affinity exporter: nothing written here has been opened in Affinity.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use crate::stream::Tag;
|
||||
|
||||
/// A field value to encode.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum F {
|
||||
U8(u8),
|
||||
U32(u32),
|
||||
I32(i32),
|
||||
F32(f32),
|
||||
F64(f64),
|
||||
Bool(bool),
|
||||
Enum(u16, u16),
|
||||
Str(String),
|
||||
/// f64 vector of 2..6 values (points, rectangles, transforms).
|
||||
F64s(Vec<f64>),
|
||||
/// Fixed-size struct (colour components), 1..64 bytes.
|
||||
Struct(Vec<u8>),
|
||||
/// Array of fixed-size records, all `size` bytes.
|
||||
Records(u16, Vec<Vec<u8>>),
|
||||
U8s(Vec<u8>),
|
||||
Entry(String),
|
||||
/// Shared object: `Def` defines id with class chain and fields, `Ref` links back.
|
||||
Def(u32, Vec<Tag>, Vec<(Tag, F)>),
|
||||
Ref(u32),
|
||||
Null,
|
||||
/// Inline object.
|
||||
Obj(Tag, Vec<(Tag, F)>),
|
||||
/// Positional (untagged) object.
|
||||
Pos(Vec<F>),
|
||||
/// Array of shared objects / links.
|
||||
Shared(Vec<F>),
|
||||
/// Array of inline objects of one class.
|
||||
Objs(Tag, Vec<Vec<(Tag, F)>>),
|
||||
/// Raw bytes appended verbatim (malformed-input tests).
|
||||
Raw(Vec<u8>),
|
||||
}
|
||||
|
||||
pub fn tag(s: &[u8; 4]) -> Tag {
|
||||
Tag::of(s)
|
||||
}
|
||||
|
||||
fn put_tag(out: &mut Vec<u8>, t: Tag) {
|
||||
out.extend(t.0.to_le_bytes());
|
||||
}
|
||||
|
||||
fn code(f: &F) -> u8 {
|
||||
match f {
|
||||
F::U8(_) => 0x01,
|
||||
F::U32(_) => 0x03,
|
||||
F::I32(_) => 0x07,
|
||||
F::F32(_) => 0x09,
|
||||
F::F64(_) => 0x0a,
|
||||
F::Bool(_) => 0x29,
|
||||
F::Enum(..) => 0x2a,
|
||||
F::Str(_) => 0x2b,
|
||||
F::F64s(v) => 0x22 + v.len() as u8,
|
||||
F::Struct(b) => 0x34 + b.len() as u8,
|
||||
F::Records(..) => 0x80 | 0x2c,
|
||||
F::U8s(_) => 0x80 | 0x01,
|
||||
F::Entry(_) => 0x33,
|
||||
F::Def(..) | F::Ref(_) => 0x31,
|
||||
F::Null => 0x31,
|
||||
F::Obj(..) => 0x32,
|
||||
F::Pos(_) => 0x30,
|
||||
F::Shared(_) => 0x80 | 0x31,
|
||||
F::Objs(..) => 0x80 | 0x32,
|
||||
F::Raw(_) => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn payload(out: &mut Vec<u8>, f: &F) {
|
||||
match f {
|
||||
F::U8(v) => out.push(*v),
|
||||
F::U32(v) => out.extend(v.to_le_bytes()),
|
||||
F::I32(v) => out.extend(v.to_le_bytes()),
|
||||
F::F32(v) => out.extend(v.to_le_bytes()),
|
||||
F::F64(v) => out.extend(v.to_le_bytes()),
|
||||
F::Bool(v) => out.push(u8::from(*v)),
|
||||
F::Enum(id, ver) => {
|
||||
out.extend(id.to_le_bytes());
|
||||
out.extend(ver.to_le_bytes());
|
||||
}
|
||||
F::Str(s) => {
|
||||
out.extend((s.len() as u32).to_le_bytes());
|
||||
out.extend(s.as_bytes());
|
||||
}
|
||||
F::F64s(v) => v.iter().for_each(|x| out.extend(x.to_le_bytes())),
|
||||
F::Struct(b) => out.extend(b),
|
||||
F::Records(size, recs) => {
|
||||
out.extend((recs.len() as u32).to_le_bytes());
|
||||
out.extend(size.to_le_bytes());
|
||||
recs.iter().for_each(|r| out.extend(r));
|
||||
}
|
||||
F::U8s(v) => {
|
||||
out.extend((v.len() as u32).to_le_bytes());
|
||||
out.extend(v);
|
||||
}
|
||||
F::Entry(name) => {
|
||||
put_tag(out, tag(b"Data"));
|
||||
out.extend((name.len() as u32).to_le_bytes());
|
||||
out.extend(name.as_bytes());
|
||||
}
|
||||
F::Def(id, chain, fields) => {
|
||||
out.push(1);
|
||||
out.extend(id.to_le_bytes());
|
||||
// Class chain: each level but the last carries no fields here; the last ends the chain.
|
||||
let (last, rest) = chain.split_last().expect("class chain");
|
||||
for t in rest {
|
||||
out.push(0);
|
||||
put_tag(out, *t);
|
||||
out.extend(0u16.to_le_bytes());
|
||||
out.push(0);
|
||||
}
|
||||
out.push(1);
|
||||
put_tag(out, *last);
|
||||
fields_into(out, fields, true);
|
||||
}
|
||||
F::Ref(id) => {
|
||||
out.push(2);
|
||||
out.extend(id.to_le_bytes());
|
||||
}
|
||||
F::Null => out.push(0),
|
||||
F::Obj(class, fields) => {
|
||||
out.push(1);
|
||||
put_tag(out, *class);
|
||||
out.extend(0u16.to_le_bytes());
|
||||
fields_into(out, fields, true);
|
||||
}
|
||||
F::Pos(items) => {
|
||||
for f in items {
|
||||
out.push(code(f));
|
||||
payload(out, f);
|
||||
}
|
||||
out.push(0);
|
||||
}
|
||||
F::Shared(items) => {
|
||||
out.extend((items.len() as u32).to_le_bytes());
|
||||
items.iter().for_each(|f| payload(out, f));
|
||||
}
|
||||
F::Objs(class, items) => {
|
||||
out.extend((items.len() as u32).to_le_bytes());
|
||||
put_tag(out, *class);
|
||||
out.extend(0u16.to_le_bytes());
|
||||
for fields in items {
|
||||
out.push(1);
|
||||
fields_into(out, fields, true);
|
||||
}
|
||||
}
|
||||
F::Raw(b) => out.extend(b),
|
||||
}
|
||||
}
|
||||
|
||||
fn fields_into(out: &mut Vec<u8>, fields: &[(Tag, F)], tagged: bool) {
|
||||
for (t, f) in fields {
|
||||
if let F::Raw(b) = f {
|
||||
out.extend(b);
|
||||
continue;
|
||||
}
|
||||
out.push(code(f));
|
||||
if tagged {
|
||||
put_tag(out, *t);
|
||||
}
|
||||
payload(out, f);
|
||||
}
|
||||
out.push(0);
|
||||
}
|
||||
|
||||
/// A `doc.dat` stream with root class `Pers`.
|
||||
pub fn stream(root: &[(Tag, F)]) -> Vec<u8> {
|
||||
let mut out = b"\x00\xffKS".to_vec();
|
||||
out.extend(2u16.to_le_bytes());
|
||||
put_tag(&mut out, tag(b"Pers"));
|
||||
out.extend(0u16.to_le_bytes());
|
||||
out.extend(30u32.to_le_bytes());
|
||||
fields_into(&mut out, root, true);
|
||||
out
|
||||
}
|
||||
|
||||
/// How an entry is stored.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Method {
|
||||
Stored,
|
||||
Zlib,
|
||||
Zstd,
|
||||
}
|
||||
|
||||
/// A version-12 container with one `#FT4` table holding `entries`, then an optional thumbnail record.
|
||||
pub fn container(entries: &[(&str, &[u8], Method)], thumbnail_png: Option<&[u8]>) -> Vec<u8> {
|
||||
let mut out = vec![0u8; 72];
|
||||
out[..4].copy_from_slice(crate::container::MAGIC);
|
||||
out[4..6].copy_from_slice(&12u16.to_le_bytes());
|
||||
out[6..8].copy_from_slice(&4u16.to_le_bytes());
|
||||
out[8..12].copy_from_slice(b"nsrP");
|
||||
out[12..16].copy_from_slice(b"#Inf");
|
||||
out[64..68].copy_from_slice(b"Prot");
|
||||
let mut records = Vec::new();
|
||||
for (i, (name, data, method)) in entries.iter().enumerate() {
|
||||
let packed = match method {
|
||||
Method::Stored => data.to_vec(),
|
||||
Method::Zlib => {
|
||||
use std::io::Write;
|
||||
let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast());
|
||||
e.write_all(data).unwrap();
|
||||
e.finish().unwrap()
|
||||
}
|
||||
Method::Zstd => ruzstd::encoding::compress_to_vec(*data, ruzstd::encoding::CompressionLevel::Fastest),
|
||||
};
|
||||
let offset = out.len() as u64;
|
||||
out.extend(b"#Fil");
|
||||
out.extend(&packed);
|
||||
let mut r = Vec::new();
|
||||
r.extend((i as u32 + 1).to_le_bytes());
|
||||
r.push(0);
|
||||
r.extend(offset.to_le_bytes());
|
||||
r.extend((data.len() as u64).to_le_bytes());
|
||||
r.extend((packed.len() as u64).to_le_bytes());
|
||||
r.extend(crc32fast::hash(data).to_le_bytes());
|
||||
r.push(match method {
|
||||
Method::Stored => 0,
|
||||
Method::Zlib => 1,
|
||||
Method::Zstd => 2,
|
||||
});
|
||||
r.extend(0u32.to_le_bytes());
|
||||
r.extend(0u32.to_le_bytes());
|
||||
r.extend((name.len() as u16).to_le_bytes());
|
||||
r.extend(name.as_bytes());
|
||||
records.push(r);
|
||||
}
|
||||
let fat = out.len() as u64;
|
||||
out.extend(b"#FT4");
|
||||
out.extend(0u64.to_le_bytes());
|
||||
out.extend(1_700_000_000u64.to_le_bytes());
|
||||
out.extend([0u8; 24]);
|
||||
out.extend((records.len() as u32).to_le_bytes());
|
||||
out.extend([0u8; 8]);
|
||||
out.extend(0u16.to_le_bytes());
|
||||
out.push(0);
|
||||
records.iter().for_each(|r| out.extend(r));
|
||||
let thumb = out.len() as u64;
|
||||
out[16..24].copy_from_slice(&fat.to_le_bytes());
|
||||
if let Some(png) = thumbnail_png {
|
||||
out[24..32].copy_from_slice(&thumb.to_le_bytes());
|
||||
out.extend(b"\xff\xff\xff\xffThmb");
|
||||
out.extend(1u32.to_le_bytes());
|
||||
out.extend((png.len() as u32 + 13).to_le_bytes());
|
||||
out.extend(29u32.to_le_bytes());
|
||||
out.extend(0u32.to_le_bytes());
|
||||
out.extend((png.len() as u32).to_le_bytes());
|
||||
out.push(1);
|
||||
out.extend(png);
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
//! Unit tests on synthetic inputs; `tests/real_files.rs` covers public Affinity documents.
|
||||
|
||||
mod preview {
|
||||
use crate::container::header;
|
||||
use crate::*;
|
||||
use proptest::prelude::*;
|
||||
|
||||
fn fixture() -> Vec<u8> {
|
||||
let mut png = b"\x89PNG\r\n\x1a\n".to_vec();
|
||||
png.extend(13u32.to_be_bytes());
|
||||
png.extend(b"IHDR");
|
||||
png.extend(2u32.to_be_bytes());
|
||||
png.extend(1u32.to_be_bytes());
|
||||
png.extend([8, 6, 0, 0, 0]);
|
||||
png.extend(crc32fast::hash(&png[12..]).to_be_bytes());
|
||||
png.extend([0; 4]);
|
||||
png.extend(b"IDAT");
|
||||
png.extend(crc32fast::hash(b"IDAT").to_be_bytes());
|
||||
png.extend([0; 4]);
|
||||
png.extend(b"IEND");
|
||||
png.extend(crc32fast::hash(b"IEND").to_be_bytes());
|
||||
let mut b = vec![0; 72];
|
||||
b[..4].copy_from_slice(MAGIC);
|
||||
b[4..6].copy_from_slice(&12u16.to_le_bytes());
|
||||
b[8..12].copy_from_slice(b"nsrP");
|
||||
b[12..16].copy_from_slice(b"#Inf");
|
||||
b[24..32].copy_from_slice(&72u64.to_le_bytes());
|
||||
b[64..68].copy_from_slice(b"Prot");
|
||||
b.extend(b"\xff\xff\xff\xffThmb");
|
||||
b.extend(1u32.to_le_bytes());
|
||||
b.extend((png.len() as u32 + 13).to_le_bytes());
|
||||
b.extend(29u32.to_le_bytes());
|
||||
b.extend(0u32.to_le_bytes());
|
||||
b.extend((png.len() as u32).to_le_bytes());
|
||||
b.push(1);
|
||||
b.extend(png);
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_preview_and_header() {
|
||||
let b = fixture();
|
||||
assert_eq!(header(&b).unwrap().version, 12);
|
||||
let p = preview(&b).unwrap();
|
||||
assert_eq!((p.width, p.height), (2, 1));
|
||||
assert_eq!(p.png, &b[101..]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_truncation_is_rejected() {
|
||||
let b = fixture();
|
||||
for end in 0..b.len() {
|
||||
assert!(preview(&b[..end]).is_err(), "{end}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_assets_versions_offsets_lengths_and_bombs() {
|
||||
let b = fixture();
|
||||
for (offset, value) in [
|
||||
(4, 13u64.to_le_bytes().to_vec()),
|
||||
(8, b"urBR".to_vec()),
|
||||
(24, u64::MAX.to_le_bytes().to_vec()),
|
||||
(24, 1u64.to_le_bytes().to_vec()),
|
||||
(24, 0u64.to_le_bytes().to_vec()),
|
||||
(84, u32::MAX.to_le_bytes().to_vec()),
|
||||
(88, 30u32.to_le_bytes().to_vec()),
|
||||
(96, u32::MAX.to_le_bytes().to_vec()),
|
||||
(117, 5000u32.to_be_bytes().to_vec()),
|
||||
] {
|
||||
let mut broken = b.clone();
|
||||
broken[offset..offset + value.len()].copy_from_slice(&value);
|
||||
assert!(preview(&broken).is_err(), "offset {offset}");
|
||||
}
|
||||
// Versions 8 to 11 store the same record (checked on public Affinity 1 and 2 files).
|
||||
for version in 8..12 {
|
||||
let mut legacy = b.clone();
|
||||
legacy[4..6].copy_from_slice(&(version as u16).to_le_bytes());
|
||||
assert!(preview(&legacy).is_ok());
|
||||
}
|
||||
// Flags 4 and 8 occur in real files; the low two bits mark other container variants.
|
||||
for (flags, ok) in [(4u16, true), (8, true), (1, false), (2, false)] {
|
||||
let mut variant = b.clone();
|
||||
variant[6..8].copy_from_slice(&flags.to_le_bytes());
|
||||
assert_eq!(preview(&variant).is_ok(), ok, "flags {flags}");
|
||||
}
|
||||
let mut old = b.clone();
|
||||
old[4..6].copy_from_slice(&7u16.to_le_bytes());
|
||||
assert!(matches!(preview(&old), Err(Error::Unsupported(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unindexed_png_or_resource_is_never_used() {
|
||||
let mut b = fixture();
|
||||
b[24..32].copy_from_slice(&0u64.to_le_bytes());
|
||||
assert!(preview(&b).is_err());
|
||||
b[24..32].copy_from_slice(&72u64.to_le_bytes());
|
||||
b[76..80].copy_from_slice(b"Meta");
|
||||
assert!(preview(&b).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_chunk_checksum_including_the_end_is_checked() {
|
||||
let b = fixture();
|
||||
for at in [130, b.len() - 1] {
|
||||
let mut damaged = b.clone();
|
||||
damaged[at] ^= 1;
|
||||
assert_eq!(preview(&damaged).unwrap_err(), Error::Malformed("PNG chunk checksum"));
|
||||
}
|
||||
let mut ancillary = vec![0; 4];
|
||||
ancillary.extend(b"tEXt");
|
||||
ancillary.extend(crc32fast::hash(b"tEXt").to_be_bytes());
|
||||
let mut with_ancillary = b.clone();
|
||||
let at = b.len() - 12;
|
||||
with_ancillary.splice(at..at, ancillary);
|
||||
let png_length = (with_ancillary.len() - 101) as u32;
|
||||
with_ancillary[84..88].copy_from_slice(&(png_length + 13).to_le_bytes());
|
||||
with_ancillary[96..100].copy_from_slice(&png_length.to_le_bytes());
|
||||
assert!(preview(&with_ancillary).is_ok());
|
||||
with_ancillary[at + 8] ^= 1;
|
||||
assert_eq!(preview(&with_ancillary).unwrap_err(), Error::Malformed("PNG chunk checksum"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compressed_metadata_and_animation_are_not_preview_support() {
|
||||
for kind in [b"zTXt", b"iTXt", b"iCCP", b"acTL", b"fcTL", b"fdAT"] {
|
||||
let mut extra = vec![0; 4];
|
||||
extra.extend(kind);
|
||||
extra.extend(crc32fast::hash(kind).to_be_bytes());
|
||||
let mut b = fixture();
|
||||
let at = b.len() - 12;
|
||||
b.splice(at..at, extra);
|
||||
let length = (b.len() - 101) as u32;
|
||||
b[84..88].copy_from_slice(&(length + 13).to_le_bytes());
|
||||
b[96..100].copy_from_slice(&length.to_le_bytes());
|
||||
assert!(matches!(preview(&b), Err(Error::Unsupported(_))));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn critical_chunks_follow_a_single_complete_png_layout() {
|
||||
fn with_chunks(kinds: &[&[u8; 4]]) -> Vec<u8> {
|
||||
let original = fixture();
|
||||
let mut b = original[..134].to_vec(); // File, record and complete first IHDR.
|
||||
for kind in kinds {
|
||||
b.extend([0; 4]);
|
||||
b.extend(*kind);
|
||||
b.extend(crc32fast::hash(*kind).to_be_bytes());
|
||||
}
|
||||
let length = (b.len() - 101) as u32;
|
||||
b[84..88].copy_from_slice(&(length + 13).to_le_bytes());
|
||||
b[96..100].copy_from_slice(&length.to_le_bytes());
|
||||
b
|
||||
}
|
||||
assert!(preview(&with_chunks(&[b"IDAT", b"IDAT", b"IEND"])).is_ok());
|
||||
// PNG readers must not reject an otherwise valid unknown ancillary chunk just
|
||||
// because its reserved (third-letter) bit is set; future versions can define it.
|
||||
assert!(preview(&with_chunks(&[b"abct", b"IDAT", b"IEND"])).is_ok());
|
||||
for kinds in [
|
||||
vec![b"IEND"],
|
||||
vec![b"IDAT", b"IHDR", b"IEND"],
|
||||
vec![b"PLTE", b"PLTE", b"IDAT", b"IEND"],
|
||||
vec![b"IDAT", b"PLTE", b"IEND"],
|
||||
vec![b"IDAT", b"tEXt", b"IDAT", b"IEND"],
|
||||
vec![b"ABCD", b"IDAT", b"IEND"],
|
||||
vec![b"IDAT", b"IEND", b"tEXt"],
|
||||
] {
|
||||
assert!(preview(&with_chunks(&kinds)).is_err(), "{kinds:?}");
|
||||
}
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(256))]
|
||||
#[test]
|
||||
fn arbitrary_input_never_panics(b in prop::collection::vec(any::<u8>(), 0..2048)) {
|
||||
let _ = header(&b);
|
||||
let _ = preview(&b);
|
||||
}
|
||||
#[test]
|
||||
fn mutations_never_panic(edits in prop::collection::vec((0usize..200, any::<u8>()), 0..32)) {
|
||||
let mut b = fixture();
|
||||
for (i, value) in edits { if let Some(v) = b.get_mut(i) { *v = value; } }
|
||||
let _ = preview(&b);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod archive {
|
||||
use crate::Error;
|
||||
use crate::container::{Archive, Limits};
|
||||
use crate::stream::{self, Kind, Tag, Value};
|
||||
use crate::synth::{self, F, Method, tag};
|
||||
use proptest::prelude::*;
|
||||
|
||||
fn doc() -> Vec<u8> {
|
||||
synth::stream(&[
|
||||
(tag(b"Desc"), F::Str("Document".into())),
|
||||
(tag(b"Visi"), F::Bool(true)),
|
||||
(tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, 5.0, 0.0, 1.0, -2.0])),
|
||||
(tag(b"Colr"), F::Def(7, vec![tag(b"RGBA")], vec![(tag(b"_col"), F::Struct([0u8; 16].to_vec()))])),
|
||||
(tag(b"Same"), F::Ref(7)),
|
||||
(tag(b"Null"), F::Null),
|
||||
(tag(b"Kids"), F::Shared(vec![F::Def(8, vec![tag(b"Grup"), tag(b"Node")], vec![(tag(b"Desc"), F::Str("g".into()))]), F::Ref(8)])),
|
||||
(tag(b"Crvs"), F::Obj(tag(b"PCvD"), vec![(tag(b"Data"), F::Pos(vec![F::U8(0), F::U32(1), F::Bool(true), F::Records(18, vec![vec![0; 18]; 4])]))])),
|
||||
(tag(b"Enum"), F::Enum(2, 1)),
|
||||
(tag(b"Tile"), F::Entry("d/3".into())),
|
||||
(tag(b"Stat"), F::U8s(vec![4, 0, 2])),
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_method_round_trips_with_checksums() {
|
||||
let d = doc();
|
||||
for method in [Method::Stored, Method::Zlib, Method::Zstd] {
|
||||
let file = synth::container(&[("doc.dat", &d, method), ("d/3", b"tile", method)], None);
|
||||
let mut a = Archive::open(&file, Limits::default()).unwrap();
|
||||
assert_eq!(a.header.version, 12);
|
||||
assert_eq!(a.read("doc.dat").unwrap(), d);
|
||||
assert_eq!(a.read("d/3").unwrap(), b"tile");
|
||||
assert_eq!(a.extracted(), d.len() + 4);
|
||||
assert!(a.read("missing").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_stream_parses_into_shared_objects_and_links() {
|
||||
let s = stream::parse(&doc()).unwrap();
|
||||
let root = &s.objects[s.root];
|
||||
assert_eq!(root.class, Tag::of(b"Pers"));
|
||||
assert_eq!(root.get(Tag::of(b"Desc")), Some(&Value::Str("Document".into())));
|
||||
assert_eq!(root.get(Tag::of(b"Xfrm")), Some(&Value::Floats(vec![1.0, 0.0, 5.0, 0.0, 1.0, -2.0])));
|
||||
let (Some(Value::Obj(Some(a))), Some(Value::Obj(Some(b)))) = (root.get(Tag::of(b"Colr")), root.get(Tag::of(b"Same"))) else { panic!() };
|
||||
assert_eq!(a, b);
|
||||
assert_eq!(s.objects[*a].class, Tag::of(b"RGBA"));
|
||||
assert_eq!(s.objects[*a].kind, Kind::Shared);
|
||||
let Some(Value::Array(kids)) = root.get(Tag::of(b"Kids")) else { panic!() };
|
||||
assert_eq!(kids[0], kids[1]);
|
||||
let Value::Obj(Some(g)) = kids[0] else { panic!() };
|
||||
assert_eq!(s.objects[g].chain, vec![Tag::of(b"Grup"), Tag::of(b"Node")]);
|
||||
assert_eq!(root.get(Tag::of(b"Enum")), Some(&Value::Enum { id: 2, version: 1 }));
|
||||
assert_eq!(root.get(Tag::of(b"Tile")), Some(&Value::Entry("d/3".into())));
|
||||
assert_eq!(root.get(Tag::of(b"Null")), Some(&Value::Obj(None)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checksums_sizes_and_budgets_are_enforced() {
|
||||
let d = doc();
|
||||
let file = synth::container(&[("doc.dat", &d, Method::Zstd)], None);
|
||||
// Flip a byte inside the compressed payload, past the "#Fil" tag at offset 72.
|
||||
let mut broken = file.clone();
|
||||
broken[90] ^= 0x55;
|
||||
let mut a = Archive::open(&broken, Limits::default()).unwrap();
|
||||
assert!(a.read("doc.dat").is_err());
|
||||
let mut a = Archive::open(&file, Limits { max_entry: d.len() - 1, max_total: usize::MAX }).unwrap();
|
||||
assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_))));
|
||||
let mut a = Archive::open(&file, Limits { max_entry: usize::MAX, max_total: d.len() + 1 }).unwrap();
|
||||
a.read("doc.dat").unwrap();
|
||||
assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_declared_size_smaller_than_the_data_is_rejected_not_truncated() {
|
||||
let big = vec![7u8; 1 << 20];
|
||||
let mut file = synth::container(&[("doc.dat", &big, Method::Zlib)], None);
|
||||
// Rewrite the declared size in the table to 1 KiB: decoding must stop and fail.
|
||||
let fat = u64::from_le_bytes(file[16..24].try_into().unwrap()) as usize;
|
||||
let size_at = fat + 59 + 5 + 8;
|
||||
file[size_at..size_at + 8].copy_from_slice(&1024u64.to_le_bytes());
|
||||
let mut a = Archive::open(&file, Limits::default()).unwrap();
|
||||
assert_eq!(a.read("doc.dat").unwrap_err(), Error::Malformed("archive entry size mismatch"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn libraries_and_cyclic_tables_are_rejected() {
|
||||
let d = doc();
|
||||
let mut file = synth::container(&[("doc.dat", &d, Method::Stored)], None);
|
||||
let mut library = file.clone();
|
||||
library[8..12].copy_from_slice(b"rAsA");
|
||||
assert!(matches!(Archive::open(&library, Limits::default()), Err(Error::Unsupported(_))));
|
||||
let fat = u64::from_le_bytes(file[16..24].try_into().unwrap());
|
||||
let at = fat as usize + 4;
|
||||
file[at..at + 8].copy_from_slice(&fat.to_le_bytes());
|
||||
assert_eq!(Archive::open(&file, Limits::default()).unwrap_err(), Error::Malformed("allocation tables form a cycle"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_streams_fail_cleanly() {
|
||||
let deep = (0..2000).fold(Vec::new(), |inner, _| vec![(tag(b"Chld"), F::Obj(tag(b"Grup"), inner))]);
|
||||
assert_eq!(stream::parse(&synth::stream(&deep)).unwrap_err(), Error::Limit("objects nested too deeply"));
|
||||
// An array claiming four billion elements in a few bytes.
|
||||
let huge = synth::stream(&[(tag(b"Big_"), F::Raw(vec![0x81, b'_', b'g', b'i', b'B', 0xff, 0xff, 0xff, 0xff, 0]))]);
|
||||
assert_eq!(stream::parse(&huge).unwrap_err(), Error::Malformed("array longer than its data"));
|
||||
let forward = synth::stream(&[(tag(b"Link"), F::Ref(99))]);
|
||||
assert!(stream::parse(&forward).is_err());
|
||||
let twice = synth::stream(&[(tag(b"A___"), F::Def(1, vec![tag(b"RGBA")], vec![])), (tag(b"B___"), F::Def(1, vec![tag(b"RGBA")], vec![]))]);
|
||||
assert!(stream::parse(&twice).is_err());
|
||||
let unknown = synth::stream(&[(tag(b"X___"), F::Raw(vec![0x0b, 1, 2, 3, 4]))]);
|
||||
assert!(matches!(stream::parse(&unknown), Err(Error::Unsupported(_))));
|
||||
let d = doc();
|
||||
for end in 0..d.len() {
|
||||
assert!(stream::parse(&d[..end]).is_err(), "{end}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_small_compressed_entry_cannot_make_the_stream_allocate_gigabytes() {
|
||||
// Each decoded value takes about 40 bytes, and a bool array packs eight in a byte: 16 MiB
|
||||
// of zero bits, a few KiB once compressed, would ask for 2^27 values (about 4 GiB). An
|
||||
// array of one-byte values just past the budget fails the same way, before allocating.
|
||||
let field = |code: u8, n: u32, data: usize| {
|
||||
let mut f = vec![0x80 | code];
|
||||
f.extend(tag(b"Big_").0.to_le_bytes());
|
||||
f.extend(n.to_le_bytes());
|
||||
f.resize(f.len() + data, 0);
|
||||
F::Raw(f)
|
||||
};
|
||||
let bools = 1u32 << 27;
|
||||
let bytes = u32::try_from(stream::MAX_VALUES).unwrap() + 1;
|
||||
for f in [field(0x29, bools, (bools / 8) as usize), field(0x01, bytes, bytes as usize)] {
|
||||
let d = synth::stream(&[(tag(b"Big_"), f)]);
|
||||
let file = synth::container(&[("doc.dat", &d, Method::Zstd)], None);
|
||||
assert!(file.len() < d.len() / 100, "{} of {}", file.len(), d.len());
|
||||
let entry = Archive::open(&file, Limits::default()).unwrap().read("doc.dat").unwrap();
|
||||
assert_eq!(stream::parse(&entry).unwrap_err(), Error::Limit("too many values in the document stream"));
|
||||
}
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(512))]
|
||||
#[test]
|
||||
fn mutated_containers_and_streams_never_panic(edits in prop::collection::vec((0usize..4096, any::<u8>()), 1..24)) {
|
||||
let d = doc();
|
||||
for method in [Method::Stored, Method::Zlib, Method::Zstd] {
|
||||
let mut file = synth::container(&[("doc.dat", &d, method)], None);
|
||||
for (i, v) in &edits {
|
||||
let n = file.len();
|
||||
file[i % n] = *v;
|
||||
}
|
||||
if let Ok(bytes) = Archive::open(&file, Limits::default()).and_then(|mut a| a.read("doc.dat")) {
|
||||
let _ = stream::parse(&bytes);
|
||||
}
|
||||
}
|
||||
let mut s = d.clone();
|
||||
for (i, v) in &edits {
|
||||
let n = s.len();
|
||||
s[i % n] = *v;
|
||||
}
|
||||
let _ = stream::parse(&s);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
//! Artistic and frame text: the story's characters with their font, size, colour, tracking and
|
||||
//! leading, the paragraph alignment and the first baseline. Layout (line breaks in frames, kerning,
|
||||
//! OpenType features) is left to the importing application.
|
||||
|
||||
use crate::model::{Affine, Align, Point, Reader, Text, TextRun, rect};
|
||||
use crate::paint::{self, Paint};
|
||||
use crate::stream::{ObjId, Value};
|
||||
|
||||
/// Characters read per text node (stories are split into blocks; this caps hostile ones).
|
||||
const MAX_CHARS: usize = 1 << 20;
|
||||
|
||||
pub(crate) fn read(r: &mut Reader, id: ObjId, world: Affine) -> Option<Text> {
|
||||
let s = r.s;
|
||||
let story = s.obj(id, b"StSt")?;
|
||||
let frame = s.obj(id, b"TxtH")?;
|
||||
let artistic = s.is(frame, b"ArFr");
|
||||
let bounds = s.floats::<4>(frame, b"FrmB").map(rect)?;
|
||||
let mut runs = Vec::new();
|
||||
let mut align = None;
|
||||
let mut total = 0usize;
|
||||
let mut placeholders = false;
|
||||
for block in s.objs(story, b"Blok") {
|
||||
let glyphs = s.obj(block, b"Glyp")?;
|
||||
let chars: Vec<char> = match s.str(glyphs, b"Utf8") {
|
||||
Some(t) => t.chars().collect(),
|
||||
None => {
|
||||
// Inline objects (page numbers, anchors) each take one index before the segment text.
|
||||
let mut v = Vec::new();
|
||||
for seg in s.objs(glyphs, b"Mixd") {
|
||||
let inline = s.objs(seg, b"Glys").len();
|
||||
v.extend(std::iter::repeat_n('\u{FFFC}', inline));
|
||||
placeholders |= inline > 0;
|
||||
v.extend(s.str(seg, b"Utf8").unwrap_or_default().chars());
|
||||
}
|
||||
v
|
||||
}
|
||||
};
|
||||
total = total.checked_add(chars.len())?;
|
||||
if total > MAX_CHARS {
|
||||
r.warn("text longer than a million characters (truncated)");
|
||||
break;
|
||||
}
|
||||
let mut start = 0usize;
|
||||
for run in s.obj(block, b"GAtt").map(|g| s.objs(g, b"Runs")).unwrap_or_default() {
|
||||
let end = usize::try_from(s.int(run, b"Indx")?).ok()?.min(chars.len());
|
||||
if end <= start {
|
||||
continue;
|
||||
}
|
||||
let text: String = chars.get(start..end)?.iter().filter(|c| **c != '\0' && **c != '\u{FFFC}').collect();
|
||||
start = end;
|
||||
let Some(attrs) = s.obj(run, b"Item") else { continue };
|
||||
if !text.is_empty() {
|
||||
runs.push(run_attrs(r, attrs, text, world));
|
||||
}
|
||||
}
|
||||
if align.is_none() {
|
||||
align = s.obj(block, b"PAtt").and_then(|p| s.objs(p, b"Runs").first().copied()).and_then(|run| s.obj(run, b"Item")).map(|p| paragraph_align(r, p));
|
||||
}
|
||||
}
|
||||
if placeholders {
|
||||
r.warn("text fields such as page numbers (left out of the text)");
|
||||
}
|
||||
let align = align.unwrap_or(Align::Left);
|
||||
let first_baseline = bounds.y0 + s.f64(frame, b"ArtV").unwrap_or(0.0);
|
||||
let anchor_x = match align {
|
||||
Align::Center => (bounds.x0 + bounds.x1) / 2.0,
|
||||
Align::Right => bounds.x1,
|
||||
_ => bounds.x0,
|
||||
};
|
||||
if !artistic {
|
||||
if s.objs(frame, b"ColW").len() > 1 || matches!(s.field(frame, b"ColW"), Some(Value::Array(a)) if a.len() > 1) {
|
||||
r.warn("text frames with several columns (imported as one column)");
|
||||
}
|
||||
if s.class(id) == Some(crate::stream::Tag::of(b"TxtC")) {
|
||||
r.warn("text frames with a curved outline (imported as rectangular frames)");
|
||||
}
|
||||
}
|
||||
Some(Text { runs, align, anchor: Point { x: anchor_x, y: first_baseline }, frame: if artistic { None } else { Some(bounds) }, transform: world })
|
||||
}
|
||||
|
||||
fn slot<T: Copy>(v: Option<&Value>, i: usize, f: impl Fn(&Value) -> Option<T>) -> Option<T> {
|
||||
match v? {
|
||||
Value::Array(a) => a.get(i).and_then(f),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn float(v: &Value) -> Option<f64> {
|
||||
match v {
|
||||
Value::Float(f) if f.is_finite() => Some(*f),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn int(v: &Value) -> Option<i64> {
|
||||
match v {
|
||||
Value::Int(i) => Some(*i),
|
||||
Value::UInt(u) => i64::try_from(*u).ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine) -> TextRun {
|
||||
let s = r.s;
|
||||
let doubles = s.field(attrs, b"Doub");
|
||||
let ints = s.field(attrs, b"Ints");
|
||||
let size = slot(doubles, 0, float).filter(|v| *v > 0.0 && *v < 1e6).unwrap_or(12.0 * r.dpi / 72.0);
|
||||
let tracking = slot(doubles, 4, float).unwrap_or(0.0);
|
||||
let leading = (slot(ints, 4, int) == Some(1)).then(|| slot(doubles, 13, float)).flatten().filter(|v| *v > 0.0);
|
||||
let objects = match s.field(attrs, b"Objs") {
|
||||
Some(Value::Array(a)) => a.iter().map(|v| if let Value::Obj(o) = v { *o } else { None }).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let fill = objects.first().copied().flatten().and_then(|f| paint::descriptor(r, f, world)).unwrap_or(Paint::None);
|
||||
if objects.get(1).copied().flatten().and_then(|f| paint::descriptor(r, f, world)).is_some_and(|p| p != Paint::None) {
|
||||
r.warn("outlined (stroked) text");
|
||||
}
|
||||
if slot(doubles, 10, float).is_some_and(|h| (h - 1.0).abs() > 1e-3) {
|
||||
r.warn("horizontally scaled text");
|
||||
}
|
||||
let font = s.obj(attrs, b"DFnt");
|
||||
let get = |tag: &[u8; 4]| font.and_then(|f| s.str(f, tag)).unwrap_or_default().to_string();
|
||||
TextRun {
|
||||
text,
|
||||
postscript: get(b"Post"),
|
||||
family: get(b"Famy"),
|
||||
weight: font.and_then(|f| s.int(f, b"Wegt")).unwrap_or(400),
|
||||
italic: font.and_then(|f| s.bool(f, b"Ital")).unwrap_or(false),
|
||||
size,
|
||||
tracking,
|
||||
leading,
|
||||
fill,
|
||||
}
|
||||
}
|
||||
|
||||
fn paragraph_align(r: &mut Reader, p: ObjId) -> Align {
|
||||
match slot(r.s.field(p, b"Ints"), 0, int) {
|
||||
Some(1) => Align::Center,
|
||||
Some(2) => Align::Right,
|
||||
Some(3) => Align::Justify,
|
||||
Some(0) | None => Align::Left,
|
||||
Some(_) => {
|
||||
r.warn("an unknown paragraph alignment (imported as left)");
|
||||
Align::Left
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//! Public Affinity documents: the pinned set `cargo xtask corpus --affinity` fetches into
|
||||
//! `corpus/affinity`, or any folder of `.af`/`.afdesign`/`.afphoto`/`.afpub` files named by
|
||||
//! `AFFINITY_SAMPLES` (searched recursively). Without either, these tests do nothing.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use photocraft_affinity::{Archive, Limits, stream};
|
||||
|
||||
fn files(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
for e in std::fs::read_dir(dir).into_iter().flatten().flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
files(&p, out);
|
||||
} else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| ["af", "afdesign", "afphoto", "afpub", "aftemplate"].contains(&e))
|
||||
&& std::fs::read(&p).is_ok_and(|b| b.starts_with(photocraft_affinity::MAGIC))
|
||||
{
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn samples() -> Option<PathBuf> {
|
||||
let pinned = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../corpus/affinity");
|
||||
std::env::var_os("AFFINITY_SAMPLES").map(PathBuf::from).or_else(|| pinned.join("SOURCES.md").exists().then_some(pinned))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_documents_parse_completely() {
|
||||
let Some(dir) = samples() else { return };
|
||||
let mut paths = Vec::new();
|
||||
files(&dir, &mut paths);
|
||||
assert!(!paths.is_empty(), "no Affinity documents under {dir:?}");
|
||||
let mut failures = Vec::new();
|
||||
for p in &paths {
|
||||
let bytes = std::fs::read(p).unwrap();
|
||||
let result = Archive::open(&bytes, Limits::default()).and_then(|mut a| {
|
||||
let doc = a.read("doc.dat")?;
|
||||
stream::parse(&doc).map(|s| s.objects.len())
|
||||
});
|
||||
match result {
|
||||
Ok(n) => assert!(n > 0),
|
||||
Err(e) => failures.push(format!("{}: {e}", p.display())),
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "{} of {} failed:\n{}", failures.len(), paths.len(), failures.join("\n"));
|
||||
eprintln!("{} Affinity documents parsed", paths.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_documents_map_to_the_model() {
|
||||
let Some(dir) = samples() else { return };
|
||||
let mut paths = Vec::new();
|
||||
files(&dir, &mut paths);
|
||||
let mut warnings = std::collections::BTreeMap::<String, usize>::new();
|
||||
let mut failures = Vec::new();
|
||||
let (mut nodes, mut docs) = (0usize, 0usize);
|
||||
fn count(n: &[photocraft_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(photocraft_affinity::MAGIC) {
|
||||
continue;
|
||||
}
|
||||
match photocraft_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"));
|
||||
}
|
||||
@@ -13,22 +13,23 @@ use crate::AutomationError;
|
||||
pub struct Opened {
|
||||
pub document: Document,
|
||||
pub warnings: Vec<String>,
|
||||
pub source_read_only: bool,
|
||||
}
|
||||
|
||||
/// Decode a document that was read through a filesystem capability.
|
||||
pub fn open_bytes(name: &str, bytes: &[u8]) -> Result<Opened, AutomationError> {
|
||||
if Path::new(name).extension().is_some_and(|extension| extension.eq_ignore_ascii_case(photocraft_format::EXTENSION)) {
|
||||
return Ok(Opened { document: photocraft_format::load_from_bytes(bytes)?, warnings: Vec::new() });
|
||||
return Ok(Opened { document: photocraft_format::load_from_bytes(bytes)?, warnings: Vec::new(), source_read_only: false });
|
||||
}
|
||||
let r = photocraft_io::import(name, bytes)?;
|
||||
Ok(Opened { document: r.document, warnings: r.warnings })
|
||||
Ok(Opened { document: r.document, warnings: r.warnings, source_read_only: r.source_read_only })
|
||||
}
|
||||
|
||||
/// Open a document from disk. Directory bundles and `.pcraft` ZIPs load
|
||||
/// natively; everything else goes through `photocraft-io` (PSD, PNG, …).
|
||||
pub fn open(path: &Path) -> Result<Opened, AutomationError> {
|
||||
if path.is_dir() {
|
||||
return Ok(Opened { document: photocraft_format::load_path(path)?, warnings: Vec::new() });
|
||||
return Ok(Opened { document: photocraft_format::load_path(path)?, warnings: Vec::new(), source_read_only: false });
|
||||
}
|
||||
let bytes = photocraft_format::read_file(path).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?;
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
|
||||
@@ -91,7 +91,10 @@ impl Headless {
|
||||
}
|
||||
None => Some(requested.to_string()),
|
||||
};
|
||||
let index = self.session.add_document(o.document, path);
|
||||
let index = self.session.add_document(o.document, path.filter(|_| !o.source_read_only));
|
||||
if let Some(st) = self.session.active_mut() {
|
||||
st.source_read_only = o.source_read_only;
|
||||
}
|
||||
let d = &self.session.documents()[index];
|
||||
Ok(json!({
|
||||
"index": index,
|
||||
|
||||
@@ -670,11 +670,9 @@ impl PhotocraftMcp {
|
||||
|
||||
async fn save_impl(&self, p: SaveParams) -> Result<CallToolResult, McpError> {
|
||||
if let Some(b) = self.bridge_client() {
|
||||
let Some(path) = p.path else {
|
||||
return Ok(fail("bridge mode needs `path`"));
|
||||
};
|
||||
// The bridge forwards to the running app's `app.save`, which takes a path only:
|
||||
// the extra options are headless-only. Saying so beats saving with defaults
|
||||
// The bridge forwards to the running app's `app.save`, which takes only an optional
|
||||
// path (without one it writes back to the document's own layered file, as headless
|
||||
// does), so the extra options are headless-only. Saying so beats saving with defaults
|
||||
// while the caller believes their quality or format was applied.
|
||||
let unsupported: Vec<&str> = [
|
||||
p.format.is_some().then_some("format"),
|
||||
@@ -688,7 +686,11 @@ impl PhotocraftMcp {
|
||||
if !unsupported.is_empty() {
|
||||
return Ok(fail(format!("bridge mode saves with the app's current settings; `{}` need headless mode", unsupported.join("`, `"))));
|
||||
}
|
||||
return to_result(b.call("app.save", json!({"path": path})).await);
|
||||
let params = match p.path {
|
||||
Some(path) => json!({"path": path}),
|
||||
None => json!({}),
|
||||
};
|
||||
return to_result(b.call("app.save", params).await);
|
||||
}
|
||||
let Some(r) = self
|
||||
.headless_op(move |h| {
|
||||
|
||||
@@ -288,6 +288,20 @@ async fn bridge_doc_save_rejects_headless_only_options_instead_of_saving_with_de
|
||||
}
|
||||
}
|
||||
|
||||
/// #946: like headless mode, a bridged `doc_save` without `path` reaches the app's `app.save`,
|
||||
/// which writes back to the document's own PSD, PSB or .pcraft file or refuses with its own error.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn bridge_doc_save_without_path_forwards_app_save_for_write_back() {
|
||||
let (addr, app) = fake_app().await;
|
||||
let client = connect(PhotocraftMcp::bridge(&addr, CONTROL_TOKEN).unwrap()).await;
|
||||
let saved = json_of(&call(&client, "doc_save", json!({})).await);
|
||||
assert_eq!(saved, json!({"saved": {}}), "no `path` is sent, not even null");
|
||||
let saved = json_of(&call(&client, "doc_save", json!({"path": "out.psd"})).await);
|
||||
assert_eq!(saved, json!({"saved": {"path": "out.psd"}}));
|
||||
client.cancel().await.unwrap();
|
||||
app.abort();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn bridge_response_budget_drops_connection_without_retrying_the_operation() {
|
||||
use photocraft_automation::{BridgeClient, budgets::MAX_RESPONSE_BYTES};
|
||||
@@ -366,6 +380,49 @@ fn write_image(dir: &std::path::Path, name: &str, format: photocraft_codecs::For
|
||||
bytes
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn affinity_preview_warns_and_requires_a_new_save_path() {
|
||||
let dir = tmp("affinity-preview");
|
||||
let png = write_image(&dir, "preview.png", photocraft_codecs::Format::Png);
|
||||
// Synthetic v12 envelope; the native document graph is intentionally absent.
|
||||
let mut bytes = vec![0; 72];
|
||||
bytes[..4].copy_from_slice(b"\x00\xffKA");
|
||||
bytes[4..6].copy_from_slice(&12u16.to_le_bytes());
|
||||
bytes[8..12].copy_from_slice(b"nsrP");
|
||||
bytes[12..16].copy_from_slice(b"#Inf");
|
||||
bytes[24..32].copy_from_slice(&72u64.to_le_bytes());
|
||||
bytes[64..68].copy_from_slice(b"Prot");
|
||||
bytes.extend(b"\xff\xff\xff\xffThmb");
|
||||
bytes.extend(1u32.to_le_bytes());
|
||||
bytes.extend((png.len() as u32 + 13).to_le_bytes());
|
||||
bytes.extend(29u32.to_le_bytes());
|
||||
bytes.extend(0u32.to_le_bytes());
|
||||
bytes.extend((png.len() as u32).to_le_bytes());
|
||||
bytes.push(1);
|
||||
bytes.extend(png);
|
||||
std::fs::write(dir.join("source.af"), &bytes).unwrap();
|
||||
// A renamed layered extension must not bypass source protection.
|
||||
std::fs::write(dir.join("renamed.psd"), &bytes).unwrap();
|
||||
let client = connect(headless_in(&dir)).await;
|
||||
for name in ["source.af", "renamed.psd"] {
|
||||
let opened = json_of(&call(&client, "doc_open", json!({"path": name})).await);
|
||||
assert!(opened["warnings"].to_string().contains("only its embedded 16×8 PNG preview"));
|
||||
assert_eq!(opened["width"], 16);
|
||||
let refused = call(&client, "doc_save", json!({})).await;
|
||||
assert_eq!(refused.is_error, Some(true), "{}", text(&refused));
|
||||
assert!(text(&refused).contains("pass `path`"), "{}", text(&refused));
|
||||
let native = call(&client, "doc_save", json!({"path": "source.af"})).await;
|
||||
assert_eq!(native.is_error, Some(true));
|
||||
assert!(text(&native).contains("Affinity export"));
|
||||
assert_eq!(std::fs::read(dir.join(name)).unwrap(), bytes);
|
||||
}
|
||||
json_of(&call(&client, "doc_save", json!({"path": "copy.pcraft"})).await);
|
||||
assert_eq!(json_of(&call(&client, "doc_save", json!({})).await)["path"], "copy.pcraft");
|
||||
assert_eq!(std::fs::read(dir.join("source.af")).unwrap(), bytes);
|
||||
client.cancel().await.unwrap();
|
||||
cleanup(&dir);
|
||||
}
|
||||
|
||||
/// A save without `path` writes back only to a layered file in its own format (#416).
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn save_without_path_never_flattens_over_the_opened_file() {
|
||||
@@ -486,6 +543,7 @@ async fn fake_app_with_screenshot(screenshot_png: Option<Vec<u8>>) -> (String, t
|
||||
"engine.execute" => {
|
||||
json!({"id": id, "ok": true, "result": {"ran": req["params"]["command"], "params": req["params"]["params"], "wait": req["params"]["wait"]}})
|
||||
}
|
||||
"app.save" => json!({"id": id, "ok": true, "result": {"saved": req["params"]}}),
|
||||
"engine.commands" => {
|
||||
json!({"id": id, "ok": true, "result": [{"id": "file.new", "label": "New…", "enabled": true}]})
|
||||
}
|
||||
|
||||
@@ -151,6 +151,20 @@ impl BlendMode {
|
||||
/// Separable per-channel blend function `B(cb, cs)`.
|
||||
#[inline]
|
||||
pub fn blend_channel(mode: BlendMode, cb: f32, cs: f32) -> f32 {
|
||||
channel(mode, cb, cs, 1.0)
|
||||
}
|
||||
|
||||
/// [`blend_channel`] for 32-bit float documents, whose values may exceed 1: Linear Dodge (Add)
|
||||
/// and Divide don't clip at 1 there (Photoshop's 32-bit Add is how light is added in HDR), only
|
||||
/// at `f32::MAX`, so they stay finite. Every other mode is [`blend_channel`].
|
||||
#[inline]
|
||||
pub fn blend_channel_hdr(mode: BlendMode, cb: f32, cs: f32) -> f32 {
|
||||
channel(mode, cb, cs, f32::MAX)
|
||||
}
|
||||
|
||||
/// `B(cb, cs)` with Linear Dodge and Divide clipped at `hi` (1 at integer depths).
|
||||
#[inline]
|
||||
fn channel(mode: BlendMode, cb: f32, cs: f32, hi: f32) -> f32 {
|
||||
match mode {
|
||||
BlendMode::Normal | BlendMode::Dissolve | BlendMode::PassThrough => cs,
|
||||
BlendMode::Darken => cb.min(cs),
|
||||
@@ -160,7 +174,7 @@ pub fn blend_channel(mode: BlendMode, cb: f32, cs: f32) -> f32 {
|
||||
BlendMode::Lighten => cb.max(cs),
|
||||
BlendMode::Screen => cb + cs - cb * cs,
|
||||
BlendMode::ColorDodge => color_dodge(cb, cs),
|
||||
BlendMode::LinearDodge => (cb + cs).min(1.0),
|
||||
BlendMode::LinearDodge => (cb + cs).min(hi),
|
||||
BlendMode::Overlay => hard_light(cs, cb),
|
||||
BlendMode::SoftLight => soft_light_ps(cb, cs),
|
||||
BlendMode::HardLight => hard_light(cb, cs),
|
||||
@@ -195,7 +209,7 @@ pub fn blend_channel(mode: BlendMode, cb: f32, cs: f32) -> f32 {
|
||||
if cs <= 0.0 {
|
||||
if cb <= 0.0 { 0.0 } else { 1.0 }
|
||||
} else {
|
||||
(cb / cs).min(1.0)
|
||||
(cb / cs).min(hi)
|
||||
}
|
||||
}
|
||||
// Non-separable modes are handled by `blend_rgb`; fall back to source.
|
||||
@@ -337,6 +351,11 @@ pub fn blend_rgb(mode: BlendMode, cb: [f32; 3], cs: [f32; 3]) -> [f32; 3] {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`blend_rgb`] for 32-bit float documents: separable modes use [`blend_channel_hdr`].
|
||||
pub fn blend_rgb_hdr(mode: BlendMode, cb: [f32; 3], cs: [f32; 3]) -> [f32; 3] {
|
||||
if mode.is_separable() { std::array::from_fn(|i| blend_channel_hdr(mode, cb[i], cs[i])) } else { blend_rgb(mode, cb, cs) }
|
||||
}
|
||||
|
||||
/// Composite a straight-alpha source over a straight-alpha backdrop with blend mode and opacity.
|
||||
///
|
||||
/// Uses the W3C/PDF general formula:
|
||||
@@ -476,6 +495,38 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_and_divide_clip_at_integer_depths_but_not_in_float() {
|
||||
// Integer depths: Photoshop clips Linear Dodge (Add) and Divide at 1.
|
||||
assert_eq!(blend_channel(BlendMode::LinearDodge, 2.0, 0.5), 1.0);
|
||||
assert_eq!(blend_channel(BlendMode::LinearDodge, 0.75, 0.5), 1.0);
|
||||
assert_eq!(blend_channel(BlendMode::Divide, 2.0, 0.5), 1.0);
|
||||
assert_eq!(blend_channel(BlendMode::Divide, 0.5, 0.25), 1.0);
|
||||
// 32-bit float: light adds up past 1 (adding light never darkens).
|
||||
assert!(close(blend_channel_hdr(BlendMode::LinearDodge, 2.0, 0.5), 2.5));
|
||||
assert!(close(blend_channel_hdr(BlendMode::LinearDodge, 0.75, 0.5), 1.25));
|
||||
assert!(close(blend_channel_hdr(BlendMode::Divide, 2.0, 0.5), 4.0));
|
||||
assert!(close(blend_channel_hdr(BlendMode::Divide, 0.5, 0.25), 2.0));
|
||||
assert_eq!(blend_rgb_hdr(BlendMode::LinearDodge, [2.0, 0.5, 4.0], [0.5, 0.25, 0.5]), [2.5, 0.75, 4.5]);
|
||||
assert_eq!(blend_rgb_hdr(BlendMode::Divide, [2.0, 0.5, 4.0], [0.5, 0.25, 0.5]), [4.0, 2.0, 8.0]);
|
||||
// Divide by zero keeps its guard; overflow stays finite.
|
||||
assert_eq!(blend_channel_hdr(BlendMode::Divide, 0.0, 0.0), 0.0);
|
||||
assert_eq!(blend_channel_hdr(BlendMode::Divide, 3.0, 0.0), 1.0);
|
||||
assert_eq!(blend_channel_hdr(BlendMode::Divide, 3.0, -1.0), 1.0);
|
||||
assert_eq!(blend_channel_hdr(BlendMode::Divide, f32::MAX, 1e-30), f32::MAX);
|
||||
assert_eq!(blend_channel_hdr(BlendMode::LinearDodge, f32::MAX, f32::MAX), f32::MAX);
|
||||
assert!(blend_channel_hdr(BlendMode::LinearDodge, f32::INFINITY, 1.0).is_finite());
|
||||
// Every other mode is unchanged, and the in-range results agree.
|
||||
for m in BlendMode::LAYER_MODES.into_iter().filter(|m| !matches!(m, BlendMode::LinearDodge | BlendMode::Divide)) {
|
||||
for (cb, cs) in [(0.2, 0.3), (0.9, 0.05), (2.0, 0.5), (0.4, 1.5)] {
|
||||
assert_eq!(blend_rgb(m, [cb, cs, 0.5], [cs, cb, 0.25]), blend_rgb_hdr(m, [cb, cs, 0.5], [cs, cb, 0.25]), "{m:?} cb={cb} cs={cs}");
|
||||
}
|
||||
}
|
||||
let (cb, cs) = ([0.2, 0.3, 0.1], [0.3, 0.4, 0.4]);
|
||||
assert_eq!(blend_rgb_hdr(BlendMode::LinearDodge, cb, cs), blend_rgb(BlendMode::LinearDodge, cb, cs));
|
||||
assert_eq!(blend_rgb_hdr(BlendMode::Divide, cb, cs), blend_rgb(BlendMode::Divide, cb, cs));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn luminosity_and_color_swap_components() {
|
||||
let cb = [0.8, 0.2, 0.2];
|
||||
|
||||
@@ -586,7 +586,9 @@ fn glow_map(shape: &Map, g: &Glow, inner: bool, origin: (i32, i32)) -> Map {
|
||||
};
|
||||
let mut m = match g.technique {
|
||||
GlowTechnique::Precise => {
|
||||
let d = if inner && g.source == GlowSource::Edge { dist_inside(shape) } else { dist_outside(&src) };
|
||||
// An inner glow, from either source, measures from the edge inwards; a centre glow is
|
||||
// the inverse below (the distance outside is ≤ 0 everywhere inside, #966).
|
||||
let d = if inner { dist_inside(shape) } else { dist_outside(&src) };
|
||||
let solid = g.size * g.spread;
|
||||
let soft = (g.size - solid).max(1e-3);
|
||||
let mut m = Map::new(shape.w, shape.h, 0.0);
|
||||
@@ -1549,9 +1551,9 @@ fn mix_premul(a: [f32; 4], b: [f32; 4], k: f32) -> [f32; 4] {
|
||||
/// `glow_map`, `bevel_maps` and `build_maps`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum FieldKind {
|
||||
/// `dist_outside` of the shape: precise outer and centre glows, bevels.
|
||||
/// `dist_outside` of the shape: precise outer glows, bevels.
|
||||
Outside,
|
||||
/// `dist_inside` of the shape: precise edge inner glows, bevels.
|
||||
/// `dist_inside` of the shape: precise inner glows, bevels.
|
||||
Inside,
|
||||
/// Chamfer distance outside the shape: outside strokes, and the spread of drop shadows and
|
||||
/// softer outer glows ([`dilate`]).
|
||||
|
||||
@@ -96,8 +96,10 @@ pub fn render_tiled(doc: &Document, rect: Rect, tile: i32) -> Buffer {
|
||||
|
||||
fn render_tiled_with(doc: &Document, rect: Rect, tile: i32, cx: &Ctx) -> Buffer {
|
||||
let tile = tile.max(1);
|
||||
// Lab documents mix Normal blending in CIELAB, as Photoshop does (psblend::LAB_MIX).
|
||||
// Lab documents mix Normal blending in CIELAB, as Photoshop does (psblend::LAB_MIX); 32-bit
|
||||
// documents don't clip Add / Divide at 1 (psblend::HDR).
|
||||
let lab = doc.mode == photocraft_color::ColorMode::Lab;
|
||||
let hdr = doc.depth == photocraft_color::SampleType::F32;
|
||||
// CMYK layers are read through the document's own CMYK profile (thread-local scope).
|
||||
let cmyk = cmyk_space(doc);
|
||||
let cmyk = cmyk.as_ref();
|
||||
@@ -107,18 +109,18 @@ fn render_tiled_with(doc: &Document, rect: Rect, tile: i32, cx: &Ctx) -> Buffer
|
||||
if rect.width() as i32 <= tile && rect.height() as i32 <= tile {
|
||||
return photocraft_color::convert::with_cmyk_space(cmyk, || {
|
||||
let mut buf = multichannel::backdrop(doc, rect);
|
||||
psblend::with_lab_mix(lab, || composite_stack_at(&doc.layers, &mut buf, cx, advanced::Scope::ROOT));
|
||||
in_blend_space(lab, hdr, || composite_stack_at(&doc.layers, &mut buf, cx, advanced::Scope::ROOT));
|
||||
buf
|
||||
});
|
||||
}
|
||||
// Effect maps are built once, here, before any tile needs them (#276).
|
||||
prepare_effects(&doc.layers, rect, cx, |f| {
|
||||
photocraft_color::convert::with_cmyk_space(cmyk, || psblend::with_lab_mix(lab, f));
|
||||
photocraft_color::convert::with_cmyk_space(cmyk, || in_blend_space(lab, hdr, f));
|
||||
});
|
||||
let run = |t: Rect| {
|
||||
photocraft_color::convert::with_cmyk_space(cmyk, || {
|
||||
let mut b = multichannel::backdrop(doc, t);
|
||||
psblend::with_lab_mix(lab, || composite_stack_at(&doc.layers, &mut b, cx, advanced::Scope::ROOT));
|
||||
in_blend_space(lab, hdr, || composite_stack_at(&doc.layers, &mut b, cx, advanced::Scope::ROOT));
|
||||
b
|
||||
})
|
||||
};
|
||||
@@ -178,6 +180,13 @@ fn render_tiled_with(doc: &Document, rect: Rect, tile: i32, cx: &Ctx) -> Buffer
|
||||
out
|
||||
}
|
||||
|
||||
/// Runs `f` with this thread's document blend flags set ([`psblend::LAB_MIX`], [`psblend::HDR`]),
|
||||
/// then restores the values they had (also on unwind): a nested rayon job can run another tile
|
||||
/// on this thread mid-composite, and clearing the flags there broke the outer tile (#1112).
|
||||
fn in_blend_space<R>(lab: bool, hdr: bool, f: impl FnOnce() -> R) -> R {
|
||||
psblend::with_lab_mix(lab, || psblend::with_hdr(hdr, f))
|
||||
}
|
||||
|
||||
/// The document's own CMYK profile for reading its CMYK pixels (`None`: not a CMYK document,
|
||||
/// untagged, or the built-in coated CMYK). Enter it with `photocraft_color::convert::with_cmyk_space`
|
||||
/// around code that converts the document's CMYK pixels or colours to RGB.
|
||||
|
||||
@@ -76,6 +76,9 @@ thread_local! {
|
||||
/// (Photoshop composites Lab documents in Lab; an anti-aliased edge between two colours
|
||||
/// differs by up to 14 / 255 from an sRGB mix: psd-tools stroke-color-descriptors-lab).
|
||||
pub static LAB_MIX: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
/// Set while rendering a 32-bit float document: Linear Dodge (Add) and Divide don't clip at 1
|
||||
/// ([`generic::blend_rgb_hdr`]), as in Photoshop's 32-bit mode; integer depths clip.
|
||||
pub static HDR: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
}
|
||||
|
||||
/// Runs `f` with [`LAB_MIX`] set to `lab`, then restores the value it had (also on unwind). A
|
||||
@@ -92,6 +95,19 @@ pub fn with_lab_mix<R>(lab: bool, f: impl FnOnce() -> R) -> R {
|
||||
f()
|
||||
}
|
||||
|
||||
/// Runs `f` with [`HDR`] set to `hdr`, then restores the value it had (also on unwind), like
|
||||
/// [`with_lab_mix`].
|
||||
pub fn with_hdr<R>(hdr: bool, f: impl FnOnce() -> R) -> R {
|
||||
struct Restore(bool);
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
HDR.with(|h| h.set(self.0));
|
||||
}
|
||||
}
|
||||
let _restore = Restore(HDR.with(|h| h.replace(hdr)));
|
||||
f()
|
||||
}
|
||||
|
||||
/// `B(Cb, Cs)` with Photoshop's variants.
|
||||
pub fn blend_rgb(mode: BlendMode, cb: [f32; 3], cs: [f32; 3]) -> [f32; 3] {
|
||||
match mode {
|
||||
@@ -99,6 +115,7 @@ pub fn blend_rgb(mode: BlendMode, cb: [f32; 3], cs: [f32; 3]) -> [f32; 3] {
|
||||
BlendMode::HardMix => std::array::from_fn(|i| hard_mix_ps(cb[i], cs[i])),
|
||||
BlendMode::ColorBurn => std::array::from_fn(|i| color_burn(cb[i], cs[i])),
|
||||
BlendMode::ColorDodge => std::array::from_fn(|i| color_dodge(cb[i], cs[i])),
|
||||
m if HDR.with(|h| h.get()) => generic::blend_rgb_hdr(m, cb, cs),
|
||||
m => generic::blend_rgb(m, cb, cs),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +76,10 @@ fn every_blend_mode_matches_reference_on_opaque_pixels() {
|
||||
top.blend = mode;
|
||||
d.layers = vec![bottom, top];
|
||||
let got = px(&d, 0, 0);
|
||||
// A 32-bit document: Add / Divide don't clip at 1 (Divide gives 0.6 / 0.2 = 3 in red).
|
||||
psblend::HDR.with(|h| h.set(true));
|
||||
let want = blend::blend_rgb(mode, [0.6, 0.3, 0.2], [0.2, 0.7, 0.5]);
|
||||
psblend::HDR.with(|h| h.set(false));
|
||||
for i in 0..3 {
|
||||
assert!((got[i] - want[i]).abs() < 1e-5, "{mode:?}: {got:?} vs {want:?}");
|
||||
}
|
||||
@@ -373,6 +376,50 @@ fn sixteen_bit_and_float_layers_composite() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `top` blended with `mode` over an opaque `bg`, both solid, in a 4×4 RGB document of `depth`.
|
||||
fn two_solid_layers(depth: SampleType, mode: BlendMode, bg: [f32; 3], top: [f32; 3]) -> Document {
|
||||
let mut d = Document::new("hdr", Size::new(4, 4), ColorMode::Rgb, depth);
|
||||
let fmt = d.pixel_format();
|
||||
let mut b = Layer::raster("bg", fmt);
|
||||
b.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 4, 4), &photocraft_raster::from_rgba(&fmt, [bg[0], bg[1], bg[2], 1.0]));
|
||||
let mut t = Layer::raster("top", fmt);
|
||||
t.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 4, 4), &photocraft_raster::from_rgba(&fmt, [top[0], top[1], top[2], 1.0]));
|
||||
t.blend = mode;
|
||||
d.layers.push(b);
|
||||
d.layers.push(t);
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn float_documents_add_and_divide_past_white() {
|
||||
// 32-bit: Linear Dodge (Add) and Divide keep HDR values (adding light never darkens).
|
||||
let (bg, top) = ([2.0, 0.5, 4.0], [0.5, 0.25, 0.5]);
|
||||
for (mode, want) in [(BlendMode::LinearDodge, [2.5, 0.75, 4.5]), (BlendMode::Divide, [4.0, 2.0, 8.0]), (BlendMode::Multiply, [1.0, 0.125, 2.0])] {
|
||||
let d = two_solid_layers(SampleType::F32, mode, bg, top);
|
||||
for buf in [flatten(&d), render_tiled(&d, d.bounds(), 2)] {
|
||||
for p in &buf.px {
|
||||
assert!(close4(*p, [want[0], want[1], want[2], 1.0]), "{mode:?}: {p:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
// The flag doesn't leak into later renders on this thread.
|
||||
let d = two_solid_layers(SampleType::U16, BlendMode::LinearDodge, [0.8, 0.5, 0.6], top);
|
||||
assert!(close4(flatten(&d).px[0], [1.0, 0.75, 1.0, 1.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integer_documents_clip_add_and_divide_at_white() {
|
||||
let (bg, top) = ([0.8, 0.5, 0.6], [0.5, 0.25, 0.5]);
|
||||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
let f32_doc = depth == SampleType::F32;
|
||||
for (mode, clipped, hdr) in [(BlendMode::LinearDodge, [1.0, 0.75, 1.0], [1.3, 0.75, 1.1]), (BlendMode::Divide, [1.0, 1.0, 1.0], [1.6, 2.0, 1.2])] {
|
||||
let want = if f32_doc { hdr } else { clipped };
|
||||
let p = flatten(&two_solid_layers(depth, mode, bg, top)).px[5];
|
||||
assert!(close4(p, [want[0], want[1], want[2], 1.0]), "{mode:?} {depth:?}: {p:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thumbnail_dimensions() {
|
||||
let d = doc_white(400, 200);
|
||||
@@ -570,6 +617,17 @@ fn outer_and_inner_glow_regions() {
|
||||
assert!(close4(px(&d, 5, 20), [1.0; 4]));
|
||||
}
|
||||
|
||||
// A Precise inner glow from the centre is brightest in the middle and fades towards the edge,
|
||||
// like the Softer one; it used to paint nothing at all (#966).
|
||||
#[test]
|
||||
fn precise_inner_glow_from_center_paints_the_middle() {
|
||||
let d = fx_doc(vec![Effect::InnerGlow(glow(GlowTechnique::Precise, GlowSource::Center))]);
|
||||
let (center, edge) = (px(&d, 20, 20), px(&d, 10, 20));
|
||||
assert!(center[1] > 0.9 && center[0] < 0.1, "green in the middle: {center:?}");
|
||||
assert!(edge[0] > center[0] && edge[1] < center[1], "fades towards the edge: {edge:?} vs {center:?}");
|
||||
assert!(close4(px(&d, 5, 20), [1.0; 4]));
|
||||
}
|
||||
|
||||
// A glow gradient runs along the glow, not across the canvas: the first stop hugs the shape on
|
||||
// every side and later stops lie further out, opaque until the glow fades (#443).
|
||||
#[test]
|
||||
|
||||
@@ -36,6 +36,7 @@ photocraft-codecs = { workspace = true }
|
||||
flate2 = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
photocraft-affinity = { workspace = true, features = ["synth"] }
|
||||
# Synthetic camera raw files for the open tests.
|
||||
photocraft-raw = { workspace = true, features = ["testgen"] }
|
||||
# WebAssembly text -> binary for plug-in test modules (Apache-2.0 WITH LLVM-exception).
|
||||
|
||||
@@ -268,6 +268,8 @@ fn layer_via(s: &mut Session, cut: bool) -> Result<Value> {
|
||||
*crate::pixels_mut(&mut l)? = clip.surface;
|
||||
let nid = doc.insert_above(Some(id), l);
|
||||
*active = Some(nid);
|
||||
// Photoshop deselects: a following Free Transform moves the whole new layer (#1096).
|
||||
doc.selection = None;
|
||||
Ok(nid)
|
||||
})?;
|
||||
Ok(json!({"layer": nid.0}))
|
||||
@@ -917,6 +919,25 @@ mod tests {
|
||||
assert!(d.layers[2].name.ends_with("copy"));
|
||||
}
|
||||
|
||||
/// #1096: Layer via Copy and Cut deselect, as Photoshop does, so a following Free Transform
|
||||
/// moves the whole new layer; one undo brings back both the selection and the old layers.
|
||||
#[test]
|
||||
fn layer_via_copy_and_cut_deselect_in_one_step() {
|
||||
for cmd in ["layer.new.layerViaCopy", "layer.new.layerViaCut"] {
|
||||
let mut s = session();
|
||||
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 10, "height": 20})).unwrap();
|
||||
let (layers, steps) = (s.active().unwrap().doc.layers.len(), s.active().unwrap().history.past_len());
|
||||
s.execute(cmd, json!({})).unwrap();
|
||||
let st = s.active().unwrap();
|
||||
assert!(st.doc.selection.is_none(), "{cmd}: deselected");
|
||||
assert_eq!((st.doc.layers.len(), st.history.past_len()), (layers + 1, steps + 1), "{cmd}");
|
||||
assert!(s.undo());
|
||||
let st = s.active().unwrap();
|
||||
assert_eq!(st.doc.layers.len(), layers, "{cmd}: undo removes the layer");
|
||||
assert!(st.doc.selection.is_some(), "{cmd}: and restores the selection");
|
||||
}
|
||||
}
|
||||
|
||||
fn active_content(s: &Session) -> LayerContent {
|
||||
let st = s.active().unwrap();
|
||||
st.doc.layer(st.active_layer.unwrap()).unwrap().content.clone()
|
||||
|
||||
@@ -117,7 +117,7 @@ pub(crate) fn list_images(dir: &str) -> Result<Vec<String>> {
|
||||
/// Extensions the batch commands pick up from a folder.
|
||||
const OPENABLE: &[&str] = &[
|
||||
"psd", "psb", "pcraft", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "exr", "hdr", "qoi", "ico", "pnm", "ppm", "pgm", "heic", "heif",
|
||||
"hif", "dng", "cr2", "nef", "nrw", "arw", "pef", "svg", "svgz",
|
||||
"hif", "dng", "cr2", "nef", "nrw", "arw", "pef", "svg", "svgz", "af", "afdesign", "afphoto", "afpub",
|
||||
];
|
||||
|
||||
/// Whether saving `doc` as a TIFF writes Photoshop layer data (anything beyond a lone
|
||||
@@ -185,7 +185,15 @@ pub(crate) fn sanitize(name: &str) -> String {
|
||||
}
|
||||
|
||||
pub(crate) fn import(name: &str, bytes: &[u8]) -> Result<Document> {
|
||||
photocraft_io::import(name, bytes).map(|r| r.document).map_err(|e| EngineError::Other(format!("{name}: {e}")))
|
||||
let r = photocraft_io::import(name, bytes).map_err(|e| EngineError::Other(format!("{name}: {e}")))?;
|
||||
// Auxiliary imports return only a document and cannot surface the preview's fidelity warning.
|
||||
// Open has its own warning-preserving path; never silently place or process a thumbnail.
|
||||
if r.preview_only {
|
||||
return Err(EngineError::Other(format!(
|
||||
"{name}: only this Affinity file's embedded preview could be read; open it with File › Open to see the warning, or export PSD or PNG from Affinity before using it here"
|
||||
)));
|
||||
}
|
||||
Ok(r.document)
|
||||
}
|
||||
|
||||
/// What a headless save writes beyond the format: JPEG quality and TIFF layers.
|
||||
@@ -348,7 +356,10 @@ pub fn open_bytes_as(s: &mut Session, name: &str, bytes: &[u8], as_ext: Option<&
|
||||
}
|
||||
};
|
||||
// Color Settings policies (preserve / convert / discard the embedded profile).
|
||||
let (i, color) = s.open_document(doc, path);
|
||||
let (i, color) = s.open_document(doc, path.filter(|_| !r.source_read_only));
|
||||
if let Some(st) = s.active_mut() {
|
||||
st.source_read_only = r.source_read_only;
|
||||
}
|
||||
// Import notes (e.g. how a camera raw was developed, or that only its preview opened).
|
||||
Ok(json!({"document": i, "color": color, "warnings": r.warnings}))
|
||||
}
|
||||
|
||||
@@ -436,8 +436,8 @@ pub(crate) fn resolve(s: &Session, fp: &mut FilterParams, p: &Value) -> Result<(
|
||||
let layer = p.get("mapLayer").and_then(Value::as_u64);
|
||||
let img = if let Some(path) = p.get("mapPath").and_then(Value::as_str).filter(|p| !p.is_empty()) {
|
||||
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("cannot read displacement map `{path}`: {e}")))?;
|
||||
let imported = photocraft_io::import(path, &bytes).map_err(|e| EngineError::Other(format!("cannot open displacement map `{path}`: {e}")))?;
|
||||
map_from_document(&imported.document, layer)
|
||||
let imported = crate::file_cmds::import(path, &bytes).map_err(|e| EngineError::Other(format!("cannot open displacement map `{path}`: {e}")))?;
|
||||
map_from_document(&imported, layer)
|
||||
} else if let Some(d) = p.get("mapDocument") {
|
||||
let docs = s.documents();
|
||||
let found = match d {
|
||||
|
||||
@@ -427,6 +427,9 @@ impl Session {
|
||||
},
|
||||
move |s, r: photocraft_io::ImportResult| {
|
||||
let (index, color) = s.open_document(r.document, None);
|
||||
if let Some(st) = s.active_mut() {
|
||||
st.source_read_only = r.source_read_only;
|
||||
}
|
||||
Ok(json!({"document": index, "name": name_a, "warnings": r.warnings, "color": color}))
|
||||
},
|
||||
)
|
||||
|
||||
@@ -160,6 +160,8 @@ pub struct DocState {
|
||||
/// Anchor of ⇧-click range selection (the last plainly or ⌘-clicked layer).
|
||||
pub layer_anchor: Option<LayerId>,
|
||||
pub path: Option<String>,
|
||||
/// An Affinity document (or its preview) must not acquire the native source as its Save path.
|
||||
pub source_read_only: bool,
|
||||
/// Increments on every change; UIs re-render when it moves.
|
||||
pub revision: u64,
|
||||
pub saved_revision: u64,
|
||||
@@ -196,6 +198,7 @@ impl DocState {
|
||||
selected_layers: active_layer.into_iter().collect(),
|
||||
layer_anchor: active_layer,
|
||||
path,
|
||||
source_read_only: false,
|
||||
revision: 1,
|
||||
saved_revision: 1,
|
||||
last_damage: None,
|
||||
|
||||
@@ -165,7 +165,7 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// File › Import › Notes… from file bytes (what a file picker returns).
|
||||
pub fn import_notes_from(s: &mut Session, name: &str, bytes: &[u8]) -> Result<Value> {
|
||||
let src = photocraft_io::import(name, bytes).map_err(|e| EngineError::Other(format!("{name}: {e}")))?.document;
|
||||
let src = crate::file_cmds::import(name, bytes)?;
|
||||
if src.notes.is_empty() {
|
||||
return Err(EngineError::Other(format!("{name} has no notes")));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Move tool Auto-Select: pick the topmost visible layer with pixels at a canvas point (Photoshop's
|
||||
//! options bar "Auto-Select: Layer | Group", ⌘-click with the Move tool, and the canvas right-click
|
||||
//! layer list).
|
||||
//! Move tool Auto-Select: pick the topmost visible, not fully locked layer with pixels at a canvas
|
||||
//! point (Photoshop's options bar "Auto-Select: Layer | Group", ⌘-click with the Move tool, and the
|
||||
//! canvas right-click layer list).
|
||||
|
||||
use photocraft_doc::{Document, LayerContent, LayerId};
|
||||
use photocraft_geom::Rect;
|
||||
@@ -81,7 +81,9 @@ fn pick(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let names: Vec<Value> = hits.iter().filter_map(|id| doc.layer(*id)).map(|l| json!({"layer": l.id.0, "name": l.name})).collect();
|
||||
return Ok(json!({ "layers": names }));
|
||||
}
|
||||
let Some(&hit) = hits.first() else { return Ok(json!({ "layer": null })) };
|
||||
// Like Photoshop, Auto-Select clicks through a fully locked layer (its own Lock All or a
|
||||
// locked group's) to the layer under it (#1641). The right-click list above still shows it.
|
||||
let Some(&hit) = hits.iter().find(|id| !doc.effective_locks(**id).all) else { return Ok(json!({ "layer": null })) };
|
||||
let target = if p.get("target").and_then(Value::as_str) == Some("group") { top_group(&doc, hit) } else { hit };
|
||||
if p.get("select").and_then(Value::as_bool).unwrap_or(true) {
|
||||
let mode = p.get("mode").and_then(Value::as_str).unwrap_or("replace");
|
||||
@@ -222,6 +224,42 @@ mod tests {
|
||||
assert_eq!(pick(&mut s, 5, 5, "layer"), bg.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fully_locked_layer_is_clicked_through() {
|
||||
// #1641: a locked layer on top was picked, so the drag couldn't reach the layer under it.
|
||||
let (mut s, a, _) = two_squares();
|
||||
s.execute("layer.new.layer", json!({"name": "Top"})).unwrap();
|
||||
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap();
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
let top = s.active().unwrap().active_layer.unwrap();
|
||||
let lock = |s: &mut Session, id: u64, locks: Value| s.execute("layer.setProps", json!({"layer": id, "locks": locks})).unwrap();
|
||||
let pick = |s: &mut Session| s.execute("layer.pickAt", json!({"x": 5, "y": 5})).unwrap()["layer"].clone();
|
||||
assert_eq!(pick(&mut s), top.0);
|
||||
// A position lock alone doesn't hide it from Auto-Select.
|
||||
lock(&mut s, top.0, json!({"position": true}));
|
||||
assert_eq!(pick(&mut s), top.0);
|
||||
lock(&mut s, top.0, json!({"all": true}));
|
||||
assert_eq!(pick(&mut s), a.0);
|
||||
assert_eq!(s.active().unwrap().active_layer, Some(a));
|
||||
// The right-click layer list still names it.
|
||||
let list = s.execute("layer.pickAt", json!({"x": 5, "y": 5, "list": true})).unwrap();
|
||||
assert_eq!(list["layers"][0]["layer"], top.0);
|
||||
// A locked group locks the layers inside it.
|
||||
lock(&mut s, top.0, json!({"all": false, "position": false}));
|
||||
s.execute("layer.select", json!({"layer": top.0})).unwrap();
|
||||
let g = s.execute("layer.new.groupFromLayers", json!({"name": "G"})).unwrap()["layer"].as_u64().unwrap();
|
||||
assert_eq!(pick(&mut s), top.0);
|
||||
lock(&mut s, g, json!({"all": true}));
|
||||
assert_eq!(pick(&mut s), a.0);
|
||||
// Nothing unlocked under the point: no pick.
|
||||
lock(&mut s, a.0, json!({"all": true}));
|
||||
let bg = s.active().unwrap().doc.layers[0].id;
|
||||
assert_eq!(pick(&mut s), bg.0);
|
||||
lock(&mut s, bg.0, json!({"all": true}));
|
||||
assert_eq!(pick(&mut s), Value::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_params_are_errors_or_misses_not_panics() {
|
||||
let (mut s, _, _) = two_squares();
|
||||
|
||||
@@ -1410,6 +1410,8 @@ fn prefs_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, e));
|
||||
}
|
||||
}
|
||||
// `colorSettings.blendTextGamma` lives in the compositor too, as `edit.colorSettings` sets it.
|
||||
photocraft_compose::psblend::set_text_gamma(s.color.settings.blend_text_gamma);
|
||||
s.apply_prefs();
|
||||
let view = s.prefs_view();
|
||||
let out: Map<String, Value> = changes.iter().map(|(k, _)| (k.clone(), get_path(&view, k).cloned().unwrap_or(Value::Null))).collect();
|
||||
@@ -1439,6 +1441,7 @@ fn prefs_reset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
s.prefs.edit(|_| ());
|
||||
photocraft_compose::psblend::set_text_gamma(s.color.settings.blend_text_gamma);
|
||||
s.apply_prefs();
|
||||
if path.is_some_and(|path| keyed(path).is_some()) {
|
||||
// The removed override is absent, so reading its old path would report an error.
|
||||
|
||||
@@ -64,7 +64,7 @@ pub fn encode_source(doc: &Document) -> Result<Vec<u8>> {
|
||||
|
||||
/// Decodes smart-object contents (a `.pcraft` bundle, PSD/PSB, or any flat image format).
|
||||
pub fn decode_source(file_name: &str, bytes: &[u8]) -> Result<Document> {
|
||||
photocraft_io::import(file_name, bytes).map(|r| r.document).map_err(|e| other(format!("can't read smart object contents \"{file_name}\": {e}")))
|
||||
crate::file_cmds::import(file_name, bytes).map_err(|e| other(format!("can't read smart object contents \"{file_name}\": {e}")))
|
||||
}
|
||||
|
||||
fn base_name(path: &str) -> String {
|
||||
|
||||
@@ -264,11 +264,21 @@ pub fn split_selected(surf: &Surface, sel: &Surface) -> (Surface, Surface) {
|
||||
let mut lp = px.clone();
|
||||
let mut rp = px;
|
||||
let w = src.width() as usize;
|
||||
// A pixel the selection fully lifts out is reset to the surface's (transparent) default;
|
||||
// keeping its colour would make `content_bounds` still count it, so the layer's bounds would
|
||||
// include the area the pixels were moved from.
|
||||
let mut rest_clear = rest.default_pixel();
|
||||
if rest_clear.last().is_some_and(|a| *a > 0.0) {
|
||||
rest_clear.fill(0.0);
|
||||
}
|
||||
for (i, (l, r)) in lp.chunks_exact_mut(n).zip(rp.chunks_exact_mut(n)).enumerate() {
|
||||
let (x, y) = (src.x0 + (i % w) as i32, src.y0 + (i / w) as i32);
|
||||
let k = sel.sample_channel(x, y, 0);
|
||||
l[n - 1] *= k;
|
||||
r[n - 1] *= 1.0 - k;
|
||||
if r[n - 1] <= 0.0 {
|
||||
r.iter_mut().zip(&rest_clear).for_each(|(d, c)| *d = *c);
|
||||
}
|
||||
}
|
||||
lifted.write_region(src, &lp);
|
||||
lifted.prune();
|
||||
@@ -491,6 +501,16 @@ mod tests {
|
||||
assert_eq!((sel.y0, sel.y1), (60, 70), "selection moves too");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_selection_bounds_drop_the_vacated_area() {
|
||||
// #1097: moving every pixel through a selection left transparent but coloured pixels
|
||||
// behind, so the layer bounds were the union of the old and new positions.
|
||||
let mut s = session();
|
||||
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 10})).unwrap();
|
||||
s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 50, 0]})).unwrap();
|
||||
assert_eq!(active_bounds(&s), Rect::new(60, 10, 80, 20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_layers_transform_as_vectors() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -193,7 +193,7 @@ fn replace_pixels(doc: &mut Document, layer: LayerId, path: &str, method: PixelM
|
||||
}
|
||||
let fmt = doc.pixel_format();
|
||||
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("read `{path}`: {e}")))?;
|
||||
let src = photocraft_io::import(path, &bytes).map_err(|e| EngineError::Other(format!("`{path}`: {e}")))?.document;
|
||||
let src = crate::file_cmds::import(path, &bytes)?;
|
||||
let img = crate::file_cmds::flattened(&src, fmt);
|
||||
let (iw, ih) = (src.size.width.max(1) as f64, src.size.height.max(1) as f64);
|
||||
let (tw, th) = (target.width() as f64, target.height() as f64);
|
||||
|
||||
@@ -207,7 +207,7 @@ fn load_frames(path: &str, fmt: photocraft_color::PixelFormat) -> Result<Vec<Sur
|
||||
for f in &files {
|
||||
let bytes = std::fs::read(f).map_err(|e| EngineError::Other(format!("read `{}`: {e}", f.display())))?;
|
||||
let name = f.to_string_lossy();
|
||||
let doc = photocraft_io::import(&name, &bytes).map_err(|e| EngineError::Other(format!("`{}`: {e}", f.display())))?.document;
|
||||
let doc = crate::file_cmds::import(&name, &bytes)?;
|
||||
frames.push(crate::file_cmds::flattened(&doc, fmt));
|
||||
}
|
||||
Ok(frames)
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
//! An Affinity document is never a save target for its source; a native one can be placed, a
|
||||
//! preview fallback cannot.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use photocraft_engine::Session;
|
||||
use photocraft_engine::file_cmds::open_bytes_as;
|
||||
use photocraft_engine::jobs::{OpenSource, Started};
|
||||
use serde_json::json;
|
||||
|
||||
fn fixture() -> Vec<u8> {
|
||||
let mut src = Session::new();
|
||||
src.execute("file.new", json!({"width": 2, "height": 1, "background": "#dc283c"})).unwrap();
|
||||
let png = photocraft_io::export(&src.active().unwrap().doc, "preview.png", &Default::default()).unwrap().bytes;
|
||||
// Observed v12 preview envelope, without a native object graph.
|
||||
let mut bytes = vec![0; 72];
|
||||
bytes[..4].copy_from_slice(b"\x00\xffKA");
|
||||
bytes[4..6].copy_from_slice(&12u16.to_le_bytes());
|
||||
bytes[8..12].copy_from_slice(b"nsrP");
|
||||
bytes[12..16].copy_from_slice(b"#Inf");
|
||||
bytes[24..32].copy_from_slice(&72u64.to_le_bytes());
|
||||
bytes[64..68].copy_from_slice(b"Prot");
|
||||
bytes.extend(b"\xff\xff\xff\xffThmb");
|
||||
bytes.extend(1u32.to_le_bytes());
|
||||
bytes.extend((png.len() as u32 + 13).to_le_bytes());
|
||||
bytes.extend(29u32.to_le_bytes());
|
||||
bytes.extend(0u32.to_le_bytes());
|
||||
bytes.extend((png.len() as u32).to_le_bytes());
|
||||
bytes.push(1);
|
||||
bytes.extend(png);
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_never_saves_back_to_its_native_or_renamed_source() {
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-affinity-source-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let bytes = fixture();
|
||||
for name in ["source.af", "renamed.psd"] {
|
||||
let path = dir.join(name).to_string_lossy().into_owned();
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
let mut s = Session::new();
|
||||
let r = open_bytes_as(&mut s, &path, &bytes, None, Some(path.clone())).unwrap();
|
||||
assert!(r["warnings"].to_string().contains("embedded"));
|
||||
assert!(s.active().unwrap().path.is_none(), "{name}");
|
||||
assert!(s.active().unwrap().source_read_only);
|
||||
assert!(!s.is_enabled("file.revert"));
|
||||
assert_eq!(std::fs::read(&path).unwrap(), bytes);
|
||||
// Explicit native export is refused before any file is written.
|
||||
let native = dir.join("refused.af");
|
||||
assert!(s.execute("file.saveACopy", json!({"path": native})).is_err());
|
||||
assert!(!native.exists());
|
||||
let copy = dir.join(format!("{name}.pcraft"));
|
||||
s.execute("file.saveACopy", json!({"path": copy})).unwrap();
|
||||
assert!(photocraft_io::import("copy.pcraft", &std::fs::read(copy).unwrap()).is_ok());
|
||||
assert_eq!(std::fs::read(path).unwrap(), bytes);
|
||||
}
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_preview_retains_the_source_protection_and_warning() {
|
||||
let mut s = Session::new();
|
||||
let started = s.start_open("source.af", OpenSource::Bytes(Arc::new(fixture()))).unwrap();
|
||||
let result = match started {
|
||||
Started::Job(id) => s.wait_job(id).unwrap(),
|
||||
Started::Done(v) => v,
|
||||
};
|
||||
assert!(result["warnings"].to_string().contains("no layers"));
|
||||
assert!(s.active().unwrap().source_read_only);
|
||||
assert!(s.active().unwrap().path.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warningless_auxiliary_paths_cannot_silently_use_a_thumbnail() {
|
||||
let bytes = fixture();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
|
||||
let layers = s.active().unwrap().doc.layers.len();
|
||||
let error = photocraft_engine::file_cmds::place_bytes(&mut s, "source.af", bytes.clone(), None, &json!({})).unwrap_err().to_string();
|
||||
assert!(error.contains("embedded preview could be read"), "{error}");
|
||||
assert_eq!(s.active().unwrap().doc.layers.len(), layers);
|
||||
assert!(photocraft_engine::smart_cmds::decode_source("source.af", &bytes).unwrap_err().to_string().contains("export PSD or PNG"));
|
||||
}
|
||||
|
||||
/// A minimal native document: one 10×10 px red square at (5, 5) on a 40×20 page.
|
||||
fn native() -> Vec<u8> {
|
||||
use photocraft_affinity::synth::{self, F, Method, tag};
|
||||
let node = |x: f64, y: f64| {
|
||||
let mut r = x.to_le_bytes().to_vec();
|
||||
r.extend(y.to_le_bytes());
|
||||
r.extend([1, 0]);
|
||||
r
|
||||
};
|
||||
let red = F::Struct([1.0f32, 0.0, 0.0, 1.0].iter().flat_map(|v| v.to_le_bytes()).collect());
|
||||
let square = F::Def(
|
||||
3,
|
||||
vec![tag(b"PCrv")],
|
||||
vec![
|
||||
(
|
||||
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(5.0, 5.0), node(15.0, 5.0), node(15.0, 15.0), node(5.0, 15.0), node(5.0, 5.0)]),
|
||||
]),
|
||||
)],
|
||||
),
|
||||
),
|
||||
(
|
||||
tag(b"BFFl"),
|
||||
F::Shared(vec![F::Def(
|
||||
4,
|
||||
vec![tag(b"FDsc")],
|
||||
vec![(tag(b"FDeF"), F::Def(5, vec![tag(b"FilS")], vec![(tag(b"Colr"), F::Def(6, vec![tag(b"RGBA")], vec![(tag(b"_col"), red)]))]))],
|
||||
)]),
|
||||
),
|
||||
],
|
||||
);
|
||||
let spread = F::Def(
|
||||
2,
|
||||
vec![tag(b"Sprd")],
|
||||
vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 40.0, 20.0])), (tag(b"SprT"), F::Bool(true)), (tag(b"Chld"), F::Shared(vec![square]))],
|
||||
);
|
||||
let doc = synth::stream(&[(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))]))]);
|
||||
synth::container(&[("doc.dat", &doc, Method::Zstd)], None)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_documents_open_read_only_and_can_be_placed() {
|
||||
let bytes = native();
|
||||
let mut s = Session::new();
|
||||
let r = open_bytes_as(&mut s, "art.af", &bytes, None, Some("/source/art.af".into())).unwrap();
|
||||
assert!(!r["warnings"].to_string().contains("preview"), "{r}");
|
||||
let st = s.active().unwrap();
|
||||
assert!(st.path.is_none() && st.source_read_only);
|
||||
assert_eq!((st.doc.size.width, st.doc.size.height), (40, 20));
|
||||
s.execute("file.new", json!({"width": 64, "height": 64})).unwrap();
|
||||
let before = s.active().unwrap().doc.layers.len();
|
||||
photocraft_engine::file_cmds::place_bytes(&mut s, "art.af", bytes, None, &json!({})).unwrap();
|
||||
assert_eq!(s.active().unwrap().doc.layers.len(), before + 1);
|
||||
}
|
||||
@@ -27,4 +27,21 @@ fn blend_text_gamma_setting() {
|
||||
s.execute("edit.colorSettings", json!({"blendTextGamma": true})).unwrap();
|
||||
assert!((s.color.settings.blend_text_gamma - 1.45).abs() < 1e-6);
|
||||
assert!((photocraft_compose::psblend::text_gamma() - 1.45).abs() < 1e-6);
|
||||
|
||||
// `prefs.set` and `prefs.reset` on `colorSettings` move the compositor's gamma too (#987).
|
||||
// Same function, not a second test: tests in one binary run concurrently.
|
||||
let in_sync = |s: &Session| (photocraft_compose::psblend::text_gamma() - s.color.settings.blend_text_gamma).abs() < 1e-6;
|
||||
s.execute("edit.colorSettings", json!({"blendTextGamma": 1.8})).unwrap();
|
||||
s.execute("prefs.set", json!({"path": "colorSettings.blendTextGamma", "value": 1.0})).unwrap();
|
||||
assert_eq!(photocraft_compose::psblend::text_gamma(), 1.0);
|
||||
assert!(in_sync(&s));
|
||||
s.execute("prefs.reset", json!({"path": "colorSettings.blendTextGamma"})).unwrap();
|
||||
assert!((photocraft_compose::psblend::text_gamma() - 1.45).abs() < 1e-6);
|
||||
assert!(in_sync(&s));
|
||||
for reset in [json!({"path": "colorSettings"}), json!({})] {
|
||||
s.execute("edit.colorSettings", json!({"blendTextGamma": 1.8})).unwrap();
|
||||
s.execute("prefs.reset", reset).unwrap();
|
||||
assert!((photocraft_compose::psblend::text_gamma() - 1.45).abs() < 1e-6);
|
||||
assert!(in_sync(&s));
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,7 @@ const F_STROKE_OUT: u32 = 1024u; // effect paint: outside stroke band
|
||||
const F_FIRST: u32 = 2048u; // outside strokes: nothing accumulated yet
|
||||
const F_CHANNELS: u32 = 4096u; // lerp: per-channel weights in p0 (channel restrictions)
|
||||
const F_LAB: u32 = 65536u; // Lab document: Normal mixes in CIELAB
|
||||
const F_HDR: u32 = 262144u; // 32-bit float document: Add / Divide don't clip at 1
|
||||
const F_QUANT: u32 = 32768u; // lerp: A rounded to p0.x steps (adjustment results, integer docs)
|
||||
const F_ADD_DIFF: u32 = 16384u; // lerp: A + (B - C) premultiplied (clips on pass-through groups)
|
||||
const F_TEXT_GAMMA: u32 = 8192u; // blend / atop / fx merge: type layer, mix coverage at gamma p4.w
|
||||
@@ -152,7 +153,11 @@ fn vivid_light_generic(cb: f32, cs: f32) -> f32 {
|
||||
return min(cb / (2.0 * (1.0 - cs)), 1.0);
|
||||
}
|
||||
|
||||
const F32_MAX: f32 = 3.40282347e38;
|
||||
|
||||
fn blend_channel(mode: i32, cb: f32, cs: f32) -> f32 {
|
||||
// Linear Dodge / Divide clip at 1 at integer depths, at f32::MAX in 32-bit documents.
|
||||
let hi = select(1.0, F32_MAX, (op.flags & F_HDR) != 0u);
|
||||
switch mode {
|
||||
case 3: { return min(cb, cs); } // Darken
|
||||
case 4: { return cb * cs; } // Multiply
|
||||
@@ -161,7 +166,7 @@ fn blend_channel(mode: i32, cb: f32, cs: f32) -> f32 {
|
||||
case 8: { return max(cb, cs); } // Lighten
|
||||
case 9: { return cb + cs - cb * cs; } // Screen
|
||||
case 10: { return color_dodge(cb, cs); } // ColorDodge
|
||||
case 11: { return min(cb + cs, 1.0); } // LinearDodge
|
||||
case 11: { return min(cb + cs, hi); } // LinearDodge
|
||||
case 13: { return hard_light(cs, cb); } // Overlay
|
||||
case 14: { return soft_light_ps(cb, cs); } // SoftLight
|
||||
case 15: { return hard_light(cb, cs); } // HardLight
|
||||
@@ -179,7 +184,7 @@ fn blend_channel(mode: i32, cb: f32, cs: f32) -> f32 {
|
||||
case 22: { return max(cb - cs, 0.0); } // Subtract
|
||||
case 23: { // Divide
|
||||
if (cs <= 0.0) { return select(1.0, 0.0, cb <= 0.0); }
|
||||
return min(cb / cs, 1.0);
|
||||
return min(cb / cs, hi);
|
||||
}
|
||||
default: { return cs; } // Normal, Dissolve, PassThrough
|
||||
}
|
||||
|
||||
@@ -305,11 +305,10 @@ pub(crate) fn program_with(e: &Effect, light: &GlobalLight, vector_shape: bool,
|
||||
Effect::OuterGlow(g) | Effect::InnerGlow(g) => {
|
||||
// glow_map
|
||||
let inner = matches!(e, Effect::InnerGlow(_));
|
||||
let edge = inner && g.source == GlowSource::Edge;
|
||||
let center = inner && g.source == GlowSource::Center;
|
||||
match g.technique {
|
||||
GlowTechnique::Precise => {
|
||||
let d = b.field(if edge { FieldKind::Inside } else { FieldKind::Outside }, g.size);
|
||||
let d = b.field(if inner { FieldKind::Inside } else { FieldKind::Outside }, g.size);
|
||||
let solid = g.size * g.spread;
|
||||
let soft = (g.size - solid).max(1e-3);
|
||||
let m = b.push(stage(Kernel::MGlow, Some(d), None, [solid, soft, f32::from(u8::from(center)), 0.0], 0));
|
||||
|
||||
@@ -362,6 +362,8 @@ pub const F_FIRST: u32 = 2048;
|
||||
pub const F_CHANNELS: u32 = 4096;
|
||||
/// Lab document: Normal blending mixes in CIELAB (`psblend::LAB_MIX`).
|
||||
pub const F_LAB: u32 = 65536;
|
||||
/// 32-bit float document: Linear Dodge (Add) and Divide don't clip at 1 (`psblend::HDR`).
|
||||
pub const F_HDR: u32 = 262144;
|
||||
/// `Lerp`: A rounded to `p0.x` steps per unit (adjustment results on integer documents).
|
||||
pub const F_QUANT: u32 = 32768;
|
||||
/// `Lerp` as A + (B − C) premultiplied (layers clipped to pass-through groups).
|
||||
@@ -427,6 +429,9 @@ impl<'a> Planner<'a> {
|
||||
if self.cx.mode == photocraft_color::ColorMode::Lab {
|
||||
pass.flags |= F_LAB;
|
||||
}
|
||||
if self.cx.depth == photocraft_color::SampleType::F32 {
|
||||
pass.flags |= F_HDR;
|
||||
}
|
||||
let (a, b, c, d) = (pass.a, pass.b, pass.c, pass.d);
|
||||
self.passes.push(pass);
|
||||
for s in [a, b, c, d].into_iter().flatten() {
|
||||
|
||||
@@ -1244,6 +1244,42 @@ fn blend_mode_extremes() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn float_documents_blend_past_white() {
|
||||
// 32-bit documents hold values above 1; Linear Dodge (Add) and Divide don't clip them there
|
||||
// (integer depths do, see `blend_mode_extremes`). Values and alphas are exact in half floats.
|
||||
// Separable modes only: the non-separable ones' ClipColor is ill-conditioned past white (a
|
||||
// grey above 1 has max − lum ≈ 0), so CPU and GPU rounding diverge there.
|
||||
let Some(mut g) = gpu() else { return };
|
||||
let vals = [0.0f32, 0.25, 0.5, 1.0, 2.0, 4.0];
|
||||
let alphas = [1.0f32, 0.5, 0.25];
|
||||
for mode in BlendMode::LAYER_MODES.into_iter().filter(|m| m.is_separable()) {
|
||||
let n = vals.len() as u32;
|
||||
let mut d = Document::new("hdr", Size::new(n * 3, n), ColorMode::Rgb, SampleType::F32);
|
||||
let fmt = d.pixel_format();
|
||||
let mut bg = Layer::raster("bg", fmt);
|
||||
let mut top = Layer::raster("top", fmt);
|
||||
for (i, &b) in vals.iter().enumerate() {
|
||||
for (j, &s) in vals.iter().enumerate() {
|
||||
for (k, &a) in alphas.iter().enumerate() {
|
||||
let r = Rect::from_xywh((i * 3 + k) as i32, j as i32, 1, 1);
|
||||
bg.surface_mut().unwrap().fill_rect(r, &[b, b * 0.5, b, 1.0]);
|
||||
top.surface_mut().unwrap().fill_rect(r, &[s, s, s * 0.5, a]);
|
||||
}
|
||||
}
|
||||
}
|
||||
top.blend = mode;
|
||||
d.layers.push(bg);
|
||||
d.layers.push(top);
|
||||
check(&mut g, &d, &format!("hdr {mode:?}"));
|
||||
if mode == BlendMode::LinearDodge {
|
||||
// 4 + 0.25 at full opacity (column 5 × 3, row 1): unclipped on the GPU too.
|
||||
let px = render_to_vec(&mut g.comp, &g.device, &g.queue, &d, Rect::from_xywh(15, 1, 1, 1)).unwrap();
|
||||
assert!((px[0][0] - 4.25).abs() < 1e-3, "{:?}", px[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroked_shapes_with_clipped_layers() {
|
||||
let Some(mut g) = gpu() else { return };
|
||||
|
||||
@@ -26,6 +26,7 @@ photocraft-algo = { workspace = true }
|
||||
photocraft-psd = { workspace = true }
|
||||
photocraft-codecs = { workspace = true }
|
||||
photocraft-raw = { workspace = true }
|
||||
photocraft-affinity = { workspace = true }
|
||||
photocraft-text = { workspace = true }
|
||||
photocraft-paint = { workspace = true }
|
||||
photocraft-vector = { workspace = true }
|
||||
@@ -42,6 +43,9 @@ resvg = { version = "0.45", default-features = false, features = ["text", "syste
|
||||
resvg = { version = "0.45", default-features = false, features = ["text", "raster-images"] }
|
||||
|
||||
[dev-dependencies]
|
||||
crc32fast = "1.5"
|
||||
photocraft-affinity = { workspace = true, features = ["synth"] }
|
||||
flate2 = { workspace = true }
|
||||
photocraft-psd = { workspace = true, features = ["testgen"] }
|
||||
photocraft-raw = { workspace = true, features = ["testgen"] }
|
||||
proptest = { workspace = true }
|
||||
@@ -112,7 +112,7 @@ pub enum Channels {
|
||||
pub fn parse(key: &[u8; 4], data: &[u8], cged: Option<&[u8]>, channels: Channels) -> Adjustment {
|
||||
// A CMYK Channel Mixer (any record using the fourth, black, source) has no RGB meaning:
|
||||
// keep it raw. Mixers written by `write` leave that source at 0 and map in every mode.
|
||||
if key == b"mixr" && channels == Channels::Other && (0..).map_while(|r| bei16(data, 4 + r * 10 + 6)).any(|k| k != 0) {
|
||||
if key == b"mixr" && matches!(channels, Channels::Cmyk | Channels::Other) && (0..).map_while(|r| bei16(data, 4 + r * 10 + 6)).any(|k| k != 0) {
|
||||
return unsupported(key, data);
|
||||
}
|
||||
let mut a = parse_any(key, data, cged);
|
||||
@@ -878,8 +878,10 @@ mod tests {
|
||||
for r in 0..4 {
|
||||
cmyk.extend(i16s(&std::array::from_fn::<i16, 5, _>(|k| if k == r { 100 } else { 0 })));
|
||||
}
|
||||
assert!(matches!(parse(b"mixr", &cmyk, None, Channels::Cmyk), Adjustment::Unsupported { .. }));
|
||||
assert!(matches!(parse(b"mixr", &cmyk, None, Channels::Other), Adjustment::Unsupported { .. }));
|
||||
let ours = write(&Adjustment::ChannelMixer { matrix: [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]], monochrome: false });
|
||||
assert!(matches!(parse(b"mixr", &ours[0].1, None, Channels::Cmyk), Adjustment::ChannelMixer { .. }));
|
||||
assert!(matches!(parse(b"mixr", &ours[0].1, None, Channels::Other), Adjustment::ChannelMixer { .. }));
|
||||
assert!(matches!(parse(b"mixr", &[0, 1, 0, 0, 0, 100], None, Channels::Rgb), Adjustment::Unsupported { .. }));
|
||||
// Photo Filter: unknown version, HSB colour space, out-of-range version-3 Lab, truncated.
|
||||
|
||||
@@ -0,0 +1,620 @@
|
||||
//! Affinity documents (`.af`, `.afdesign`, `.afphoto`, `.afpub`): the native layers, curves,
|
||||
//! shapes, artboards, text, images and masks `photocraft-affinity` reads, mapped to PhotoCraft
|
||||
//! layers (groups, shape layers, type layers, embedded smart objects, pixel layers, layer and
|
||||
//! vector masks). What could not be mapped is listed in the warnings. A file whose native data
|
||||
//! can't be read opens as its embedded preview, with a warning saying why.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use photocraft_affinity::model::{self as af, Align, Blend, Kind, Pixels};
|
||||
use photocraft_affinity::paint::{self as ap, Cap, GradientKind, Join};
|
||||
use photocraft_color::{BlendMode, Color, ColorMode, PixelFormat, SampleType};
|
||||
use photocraft_doc::text::{CharStyle, ParagraphRun, ParagraphStyle, TextAlign, TextRun, TextShape};
|
||||
use photocraft_doc::vector::{Knot, LineCap, LineJoin, Path, ShapeLayer, ShapeStroke, StrokeAlign, Subpath, VectorMask};
|
||||
use photocraft_doc::{
|
||||
Artboard, ArtboardBackground, Document, Fill, GradientStyle, Group, Layer, LayerContent, LayerMask, Locks, SmartObject, SmartSource, TextLayer,
|
||||
};
|
||||
use photocraft_geom::{Affine, Point, Rect, Size};
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
use crate::{ImportResult, IoError};
|
||||
|
||||
pub use photocraft_affinity::is_affinity;
|
||||
|
||||
/// Extensions recognized as Affinity documents.
|
||||
pub const EXTENSIONS: &[&str] = &["af", "afphoto", "afdesign", "afpub"];
|
||||
|
||||
/// Pixels an image or mask is resampled into, after cropping to the canvas.
|
||||
const MAX_PIXELS: u64 = 64 << 20;
|
||||
/// Canvas side in pixels.
|
||||
const MAX_SIDE: f64 = 300_000.0;
|
||||
|
||||
pub fn has_extension(name: &str) -> bool {
|
||||
name.rsplit(['/', '\\']).next().and_then(|n| n.rsplit_once('.')).is_some_and(|(_, ext)| EXTENSIONS.iter().any(|e| ext.eq_ignore_ascii_case(e)))
|
||||
}
|
||||
|
||||
pub(crate) fn import(name: &str, bytes: &[u8]) -> Result<ImportResult, IoError> {
|
||||
match photocraft_affinity::read(bytes, photocraft_affinity::Limits::default()) {
|
||||
Ok(d) => {
|
||||
let mut b = Builder::new(name, &d)?;
|
||||
b.document(&d)?;
|
||||
let mut warnings: Vec<String> = d.warnings.iter().map(|w| format!("Affinity: not imported or approximated: {w}.")).collect();
|
||||
warnings.extend(b.warnings.iter().map(|w| format!("Affinity: {w}.")));
|
||||
// Type layers draw from their model, within the import's raster bound: the sizes and
|
||||
// frames come from an untrusted file.
|
||||
let mut document = b.doc;
|
||||
crate::text_import::prepare(&mut document);
|
||||
// Save never writes back over the Affinity file.
|
||||
Ok(ImportResult { document, warnings, source_read_only: true, preview_only: false })
|
||||
}
|
||||
Err(native) => preview(name, bytes, &native),
|
||||
}
|
||||
}
|
||||
|
||||
/// The embedded preview, for files whose native document can't be read.
|
||||
fn preview(name: &str, bytes: &[u8], native: &photocraft_affinity::Error) -> Result<ImportResult, IoError> {
|
||||
let p = photocraft_affinity::preview(bytes).map_err(|e| IoError::Unsupported(format!("{native}; its embedded preview can't be read either ({e})")))?;
|
||||
let opts = photocraft_codecs::DecodeOptions {
|
||||
limits: photocraft_codecs::Limits { max_width: 4096, max_height: 4096, max_pixels: 4096 * 4096, max_alloc: 128 << 20 },
|
||||
..Default::default()
|
||||
};
|
||||
let img = photocraft_codecs::decode_with(p.png, &opts)?;
|
||||
let mut r = crate::flat::image_to_document(name, &img)?;
|
||||
if let Some(layer) = r.document.layers.first_mut() {
|
||||
layer.name = "Affinity preview".into();
|
||||
layer.locks = Default::default();
|
||||
}
|
||||
r.source_read_only = true;
|
||||
r.preview_only = true;
|
||||
r.warnings.insert(
|
||||
0,
|
||||
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 PSD or PNG from Affinity for full-resolution artwork.",
|
||||
p.width, p.height
|
||||
),
|
||||
);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
struct Builder {
|
||||
doc: Document,
|
||||
warnings: Vec<String>,
|
||||
/// Document pixels (in the canvas) per Affinity document pixel: always 1; spreads are offset.
|
||||
format: PixelFormat,
|
||||
canvas: Rect,
|
||||
}
|
||||
|
||||
fn affine(a: af::Affine) -> Affine {
|
||||
Affine { m: a.0 }
|
||||
}
|
||||
|
||||
fn point(p: af::Point) -> Point {
|
||||
Point::new(p.x, p.y)
|
||||
}
|
||||
|
||||
impl Builder {
|
||||
fn new(name: &str, d: &af::Document) -> Result<Self, IoError> {
|
||||
// Spreads (Publisher pages) side by side, 64 px apart.
|
||||
let mut w: f64 = 0.0;
|
||||
let mut h: f64 = 0.0;
|
||||
for (i, s) in d.spreads.iter().enumerate() {
|
||||
w += (s.bounds.x1 - s.bounds.x0).max(1.0) + if i > 0 { 64.0 } else { 0.0 };
|
||||
h = h.max((s.bounds.y1 - s.bounds.y0).max(1.0));
|
||||
}
|
||||
if !(w.is_finite() && h.is_finite()) || w > MAX_SIDE || h > MAX_SIDE {
|
||||
return Err(IoError::Unsupported("the Affinity document is larger than 300 000 pixels on a side".into()));
|
||||
}
|
||||
let size = Size::new(w.ceil().max(1.0) as u32, h.ceil().max(1.0) as u32);
|
||||
let mut doc = Document::new(name, size, ColorMode::Rgb, SampleType::U8);
|
||||
doc.resolution_dpi = d.dpi as f32;
|
||||
let canvas = Rect::new(0, 0, size.width as i32, size.height as i32);
|
||||
Ok(Self { doc, warnings: Vec::new(), format: PixelFormat::RGBA8, canvas })
|
||||
}
|
||||
|
||||
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<(), IoError> {
|
||||
let pages = d.spreads.len() > 1 || d.spreads.iter().any(|s| s.pages.len() > 1);
|
||||
let mut x = 0.0;
|
||||
let mut layers = Vec::new();
|
||||
for (i, spread) in d.spreads.iter().enumerate() {
|
||||
let to_doc = Affine::translate(x - spread.bounds.x0, -spread.bounds.y0);
|
||||
let mut children: Vec<Layer> = spread.nodes.iter().filter_map(|n| self.layer(n, to_doc, true)).collect();
|
||||
if pages {
|
||||
// Each page becomes an artboard holding the art drawn on it.
|
||||
let rects: Vec<af::Rect> = if spread.pages.is_empty() { vec![spread.bounds] } else { spread.pages.clone() };
|
||||
let page = rects.first().copied().unwrap_or(spread.bounds);
|
||||
let r = self.bbox(to_doc, page);
|
||||
let mut g = Layer::new(
|
||||
format!("Page {}", i + 1),
|
||||
LayerContent::Group(Group { children: std::mem::take(&mut children), expanded: true, artboard: None }),
|
||||
);
|
||||
if let LayerContent::Group(gr) = &mut g.content {
|
||||
gr.artboard = Some(Artboard { rect: r, background: ArtboardBackground::White, preset: String::new() });
|
||||
}
|
||||
if rects.len() > 1 {
|
||||
self.warn("facing pages import as one artboard per spread");
|
||||
}
|
||||
layers.push(g);
|
||||
} else {
|
||||
layers.append(&mut children);
|
||||
}
|
||||
x += (spread.bounds.x1 - spread.bounds.x0).max(1.0) + 64.0;
|
||||
}
|
||||
if d.spreads.first().is_some_and(|s| !s.transparent) && !self.doc_has_artboards(&layers) {
|
||||
// An opaque page: a white background below the art.
|
||||
let mut bg = Surface::with_default(self.format, &[1.0, 1.0, 1.0, 1.0]);
|
||||
bg.prune();
|
||||
let mut l = Layer::new("Background", LayerContent::Raster(bg));
|
||||
l.locks.transparency = true;
|
||||
layers.insert(0, l);
|
||||
}
|
||||
self.doc.layers = layers;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn doc_has_artboards(&self, layers: &[Layer]) -> bool {
|
||||
layers.iter().any(|l| matches!(&l.content, LayerContent::Group(g) if g.artboard.is_some()))
|
||||
}
|
||||
|
||||
/// Integer bounding box of `r` mapped by `m`, clipped to the canvas.
|
||||
fn bbox(&self, m: Affine, r: af::Rect) -> Rect {
|
||||
let pts = [(r.x0, r.y0), (r.x1, r.y0), (r.x1, r.y1), (r.x0, r.y1)].map(|(x, y)| m.apply(Point::new(x, y)));
|
||||
let fold = |f: fn(f64, f64) -> f64, init: f64, g: fn(&Point) -> f64| pts.iter().map(g).fold(init, f);
|
||||
let x0 = fold(f64::min, f64::INFINITY, |p| p.x).floor();
|
||||
let y0 = fold(f64::min, f64::INFINITY, |p| p.y).floor();
|
||||
let x1 = fold(f64::max, f64::NEG_INFINITY, |p| p.x).ceil();
|
||||
let y1 = fold(f64::max, f64::NEG_INFINITY, |p| p.y).ceil();
|
||||
let c = |v: f64, lo: i32, hi: i32| (v.clamp(f64::from(lo), f64::from(hi))) as i32;
|
||||
Rect::new(
|
||||
c(x0, self.canvas.x0, self.canvas.x1),
|
||||
c(y0, self.canvas.y0, self.canvas.y1),
|
||||
c(x1, self.canvas.x0, self.canvas.x1),
|
||||
c(y1, self.canvas.y0, self.canvas.y1),
|
||||
)
|
||||
}
|
||||
|
||||
fn blend(&mut self, b: Blend) -> BlendMode {
|
||||
match b {
|
||||
Blend::Normal => BlendMode::Normal,
|
||||
Blend::PassThrough => BlendMode::PassThrough,
|
||||
Blend::Darken => BlendMode::Darken,
|
||||
Blend::DarkerColor => BlendMode::DarkerColor,
|
||||
Blend::Multiply => BlendMode::Multiply,
|
||||
Blend::ColorBurn => BlendMode::ColorBurn,
|
||||
Blend::Lighten => BlendMode::Lighten,
|
||||
Blend::LighterColor => BlendMode::LighterColor,
|
||||
Blend::Screen => BlendMode::Screen,
|
||||
Blend::ColorDodge => BlendMode::ColorDodge,
|
||||
Blend::Add => BlendMode::LinearDodge,
|
||||
Blend::Overlay => BlendMode::Overlay,
|
||||
Blend::SoftLight => BlendMode::SoftLight,
|
||||
Blend::HardLight => BlendMode::HardLight,
|
||||
Blend::VividLight => BlendMode::VividLight,
|
||||
Blend::PinLight => BlendMode::PinLight,
|
||||
Blend::LinearLight => BlendMode::LinearLight,
|
||||
Blend::HardMix => BlendMode::HardMix,
|
||||
Blend::Difference => BlendMode::Difference,
|
||||
Blend::Exclusion => BlendMode::Exclusion,
|
||||
Blend::Subtract => BlendMode::Subtract,
|
||||
Blend::Hue => BlendMode::Hue,
|
||||
Blend::Saturation => BlendMode::Saturation,
|
||||
Blend::Luminosity => BlendMode::Luminosity,
|
||||
Blend::Color => BlendMode::Color,
|
||||
Blend::Average | Blend::Negation | Blend::Reflect | Blend::Glow | Blend::Erase => {
|
||||
self.warn("the Average, Negation, Reflect, Glow and Erase blend modes import as Normal");
|
||||
BlendMode::Normal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn base(&mut self, n: &af::Node, content: LayerContent) -> Layer {
|
||||
let mut l = Layer::new(if n.name.is_empty() { default_name(&n.kind) } else { n.name.clone() }, content);
|
||||
l.visible = n.visible;
|
||||
l.opacity = n.opacity as f32;
|
||||
l.blend = self.blend(n.blend);
|
||||
if n.locked {
|
||||
l.locks = Locks { all: true, ..Locks::default() };
|
||||
}
|
||||
l
|
||||
}
|
||||
|
||||
fn group(&mut self, n: &af::Node, children: Vec<Layer>) -> Layer {
|
||||
// Pass-through and Normal (isolated) groups map one to one.
|
||||
self.base(n, LayerContent::Group(Group { children, expanded: false, artboard: None }))
|
||||
}
|
||||
|
||||
/// One Affinity node and its subtree; `to_doc` maps Affinity document pixels to the canvas.
|
||||
fn layer(&mut self, n: &af::Node, to_doc: Affine, top: bool) -> Option<Layer> {
|
||||
let children: Vec<Layer> = n.children.iter().filter_map(|c| self.layer(c, to_doc, false)).collect();
|
||||
let mut l = match &n.kind {
|
||||
Kind::Layer | Kind::Group => self.group(n, children),
|
||||
Kind::Unsupported if children.is_empty() => return None,
|
||||
Kind::Unsupported => self.group(n, children),
|
||||
Kind::Shape { path, fills, strokes, even_odd } => {
|
||||
let path = self.path(path, to_doc, *even_odd);
|
||||
let shape = self.shape(path.clone(), fills, strokes, to_doc);
|
||||
if children.is_empty() {
|
||||
self.base(n, LayerContent::Shape(shape))
|
||||
} else {
|
||||
// A shape clips its children: a group masked by the outline, the shape at its bottom.
|
||||
let mut own = Layer::new(if n.name.is_empty() { "Shape".to_string() } else { n.name.clone() }, LayerContent::Shape(shape));
|
||||
own.opacity = 1.0;
|
||||
let mut kids = vec![own];
|
||||
kids.extend(children);
|
||||
let mut g = self.group(n, kids);
|
||||
g.vector_mask = Some(VectorMask { path, enabled: true, linked: true, density: 1.0, feather: 0.0 });
|
||||
if !strokes.is_empty() {
|
||||
self.warn("strokes of shapes that clip other layers are clipped to the shape");
|
||||
}
|
||||
g
|
||||
}
|
||||
}
|
||||
Kind::Artboard { rect, background } => {
|
||||
let r = self.bbox(to_doc, *rect);
|
||||
let bg = match background.first() {
|
||||
Some(ap::Paint::Solid(c)) => {
|
||||
let c = color(*c);
|
||||
if c.alpha <= 0.0 { ArtboardBackground::Transparent } else { ArtboardBackground::Custom(c) }
|
||||
}
|
||||
Some(_) => {
|
||||
self.warn("artboard backgrounds other than a solid colour import as transparent");
|
||||
ArtboardBackground::Transparent
|
||||
}
|
||||
None => ArtboardBackground::Transparent,
|
||||
};
|
||||
let mut g = self.group(n, children);
|
||||
if top {
|
||||
if let LayerContent::Group(gr) = &mut g.content {
|
||||
gr.artboard = Some(Artboard { rect: r, background: bg, preset: String::new() });
|
||||
}
|
||||
} else {
|
||||
// PhotoCraft artboards are top-level groups: a nested one clips by a vector mask.
|
||||
let p = Path::new(vec![Subpath::polygon(&[
|
||||
(f64::from(r.x0), f64::from(r.y0)),
|
||||
(f64::from(r.x1), f64::from(r.y0)),
|
||||
(f64::from(r.x1), f64::from(r.y1)),
|
||||
(f64::from(r.x0), f64::from(r.y1)),
|
||||
])]);
|
||||
g.vector_mask = Some(VectorMask { path: p, enabled: true, linked: true, density: 1.0, feather: 0.0 });
|
||||
self.warn("artboards inside groups import as masked groups");
|
||||
}
|
||||
g
|
||||
}
|
||||
Kind::Text(t) => {
|
||||
let layer = self.text(t, to_doc);
|
||||
let l = self.base(n, LayerContent::Text(layer));
|
||||
if children.is_empty() {
|
||||
l
|
||||
} else {
|
||||
let mut kids = vec![l];
|
||||
kids.extend(children);
|
||||
self.group(n, kids)
|
||||
}
|
||||
}
|
||||
Kind::Image(img) => match self.image(img, to_doc) {
|
||||
Some(content) => self.base(n, content),
|
||||
None if children.is_empty() => return None,
|
||||
None => self.group(n, children),
|
||||
},
|
||||
};
|
||||
if let Some(mask) = &n.mask {
|
||||
l.vector_mask = Some(VectorMask { path: self.path(mask, to_doc, false), enabled: true, linked: true, density: 1.0, feather: 0.0 });
|
||||
}
|
||||
if let Some(mask) = &n.pixel_mask
|
||||
&& let Some(surface) = self.mask(mask, to_doc)
|
||||
{
|
||||
l.mask = Some(LayerMask { surface, enabled: true, linked: true, density: 1.0, feather: 0.0 });
|
||||
}
|
||||
Some(l)
|
||||
}
|
||||
|
||||
fn path(&self, p: &af::Path, to_doc: Affine, even_odd: bool) -> Path {
|
||||
let m = to_doc;
|
||||
let subpaths = p
|
||||
.subpaths
|
||||
.iter()
|
||||
.map(|s| {
|
||||
// Knots: each anchor with the control points of the segments arriving and leaving.
|
||||
let mut knots: Vec<Knot> = Vec::with_capacity(s.segments.len() + 1);
|
||||
let start = m.apply(point(s.start));
|
||||
knots.push(Knot::corner(start.x, start.y));
|
||||
for [c1, c2, e] in &s.segments {
|
||||
if let Some(prev) = knots.last_mut() {
|
||||
prev.out_ctrl = m.apply(point(*c1));
|
||||
}
|
||||
let e = m.apply(point(*e));
|
||||
let mut k = Knot::corner(e.x, e.y);
|
||||
k.in_ctrl = m.apply(point(*c2));
|
||||
knots.push(k);
|
||||
}
|
||||
// A closed subpath that ends where it starts: the last knot is the first.
|
||||
if s.closed
|
||||
&& knots.len() > 1
|
||||
&& let (Some(f), Some(l)) = (knots.first().copied(), knots.last().copied())
|
||||
&& (f.anchor.x - l.anchor.x).abs() < 1e-9
|
||||
&& (f.anchor.y - l.anchor.y).abs() < 1e-9
|
||||
{
|
||||
knots.pop();
|
||||
if let Some(first) = knots.first_mut() {
|
||||
first.in_ctrl = l.in_ctrl;
|
||||
}
|
||||
}
|
||||
Subpath { closed: s.closed, knots, op: Default::default() }
|
||||
})
|
||||
.collect();
|
||||
let mut path = Path::new(subpaths);
|
||||
if even_odd {
|
||||
// One component filled even-odd, so inner subpaths cut holes as in Affinity.
|
||||
path.fill_rule = photocraft_doc::vector::FillRule::EvenOdd;
|
||||
for s in path.subpaths.iter_mut().skip(1) {
|
||||
s.op = photocraft_doc::vector::PathOp::Join;
|
||||
}
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
fn fill(&mut self, p: &ap::Paint, frame: Rect, to_doc: Affine) -> Option<Fill> {
|
||||
match p {
|
||||
ap::Paint::None => None,
|
||||
ap::Paint::Solid(c) => Some(Fill::Solid(color(*c))),
|
||||
ap::Paint::Gradient(g) => {
|
||||
let style = match g.kind {
|
||||
GradientKind::Linear => GradientStyle::Linear,
|
||||
GradientKind::Radial => GradientStyle::Radial,
|
||||
GradientKind::Conical => GradientStyle::Angle,
|
||||
};
|
||||
let m = to_doc.mul(&affine(g.transform));
|
||||
let (o, e, v) = (m.apply(Point::new(0.0, 0.0)), m.apply(Point::new(1.0, 0.0)), m.apply(Point::new(0.0, 1.0)));
|
||||
let (u, w) = ((e.x - o.x).hypot(e.y - o.y), (v.x - o.x).hypot(v.y - o.y));
|
||||
if style == GradientStyle::Radial && u > 0.0 && (w / u - 1.0).abs() > 0.01 {
|
||||
self.warn("elliptical gradients import as circular ones");
|
||||
}
|
||||
let (angle, scale, offset) =
|
||||
photocraft_compose::gradient_fill::from_handles(style, [o.x as f32, o.y as f32], [e.x as f32, e.y as f32], frame, 0.0);
|
||||
let stops = g.stops.iter().map(|s| (s.offset as f32, Color { alpha: 1.0, ..color(s.color) })).collect();
|
||||
let opacity_stops = g.stops.iter().map(|s| (s.offset as f32, s.color.alpha() as f32)).collect();
|
||||
let midpoints = g.stops.iter().take(g.stops.len().saturating_sub(1)).map(|s| s.half_point() as f32).collect();
|
||||
Some(Fill::Gradient { stops, angle, scale, style, reverse: false, opacity_stops, midpoints, offset, dither: false, align: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn shape(&mut self, path: Path, fills: &[ap::Paint], strokes: &[ap::Stroke], to_doc: Affine) -> ShapeLayer {
|
||||
let frame = path
|
||||
.control_bounds()
|
||||
.map(|(x0, y0, x1, y1)| Rect::new(x0.floor() as i32, y0.floor() as i32, x1.ceil() as i32, y1.ceil() as i32))
|
||||
.unwrap_or(self.canvas);
|
||||
if fills.len() > 1 || strokes.len() > 1 {
|
||||
self.warn("only one fill and one stroke per shape import");
|
||||
}
|
||||
let fill = fills.first().and_then(|f| self.fill(f, frame, to_doc));
|
||||
let stroke = strokes.first().and_then(|s| {
|
||||
let paint = self.fill(&s.paint, frame, to_doc)?;
|
||||
if s.behind {
|
||||
self.warn("strokes drawn behind their fill import in front of it");
|
||||
}
|
||||
Some(ShapeStroke {
|
||||
width: s.width as f32,
|
||||
paint,
|
||||
opacity: 1.0,
|
||||
align: match s.align {
|
||||
ap::Align::Center => StrokeAlign::Center,
|
||||
ap::Align::Inside => StrokeAlign::Inside,
|
||||
ap::Align::Outside => StrokeAlign::Outside,
|
||||
},
|
||||
cap: match s.cap {
|
||||
Cap::Butt => LineCap::Butt,
|
||||
Cap::Round => LineCap::Round,
|
||||
Cap::Square => LineCap::Square,
|
||||
},
|
||||
join: match s.join {
|
||||
Join::Miter => LineJoin::Miter,
|
||||
Join::Round => LineJoin::Round,
|
||||
Join::Bevel => LineJoin::Bevel,
|
||||
},
|
||||
miter_limit: s.miter_limit as f32,
|
||||
// In multiples of the width, as Photoshop stores them.
|
||||
dashes: s.dash.as_ref().map(|(d, _)| d.iter().map(|v| (v / s.width.max(1e-9)) as f32).collect()).unwrap_or_default(),
|
||||
dash_offset: s.dash.as_ref().map_or(0.0, |(_, o)| (o / s.width.max(1e-9)) as f32),
|
||||
})
|
||||
});
|
||||
let mut sh = ShapeLayer { path, fill, stroke, live: None, cache: None, psd_raw: None };
|
||||
sh.cache = Some(photocraft_vector::render_shape(&sh, self.format, self.canvas));
|
||||
sh
|
||||
}
|
||||
|
||||
fn text(&mut self, t: &af::Text, to_doc: Affine) -> TextLayer {
|
||||
let origin = match t.frame {
|
||||
Some(f) => Point::new(f.x0, f.y0),
|
||||
None => point(t.anchor),
|
||||
};
|
||||
let m = to_doc.mul(&affine(t.transform)).mul(&Affine::translate(origin.x, origin.y));
|
||||
let k = m.determinant().abs().sqrt().max(1e-9);
|
||||
let transform = m.mul(&Affine::scale(1.0 / k));
|
||||
let dpi = self.doc.resolution_dpi.max(1.0) as f64;
|
||||
let mut text = String::new();
|
||||
let mut runs = Vec::new();
|
||||
for r in &t.runs {
|
||||
// Affinity separates paragraphs with U+2029 and lines with U+2028.
|
||||
let s = r.text.replace(['\u{2029}', '\u{2028}'], "\r");
|
||||
let style = CharStyle {
|
||||
font_family: r.family.clone(),
|
||||
postscript_name: (!r.postscript.is_empty()).then(|| r.postscript.clone()),
|
||||
weight: r.weight.clamp(100, 900) as u16,
|
||||
italic: r.italic,
|
||||
size_pt: (r.size * k * 72.0 / dpi) as f32,
|
||||
color: match &r.fill {
|
||||
ap::Paint::Solid(c) => color(*c),
|
||||
_ => Color::BLACK,
|
||||
},
|
||||
tracking: (r.tracking * 1000.0) as f32,
|
||||
leading_pt: r.leading.map(|l| (l * k * 72.0 / dpi) as f32),
|
||||
..CharStyle::default()
|
||||
};
|
||||
text.push_str(&s);
|
||||
runs.push(TextRun { len: s.len(), style });
|
||||
}
|
||||
let align = match t.align {
|
||||
Align::Left => TextAlign::Left,
|
||||
Align::Center => TextAlign::Center,
|
||||
Align::Right => TextAlign::Right,
|
||||
Align::Justify => TextAlign::JustifyLeft,
|
||||
};
|
||||
let mut layer = TextLayer {
|
||||
text: text.clone(),
|
||||
font_family: runs.first().map(|r| r.style.font_family.clone()).unwrap_or_default(),
|
||||
size_pt: runs.first().map_or(12.0, |r| r.style.size_pt),
|
||||
color: runs.first().map_or(Color::BLACK, |r| r.style.color),
|
||||
transform,
|
||||
runs,
|
||||
paragraphs: vec![ParagraphRun { len: text.len(), style: ParagraphStyle { align, ..ParagraphStyle::default() } }],
|
||||
..TextLayer::default()
|
||||
};
|
||||
if let Some(f) = t.frame {
|
||||
layer.shape = TextShape::Box { x: 0.0, y: 0.0, width: ((f.x1 - f.x0) * k) as f32, height: ((f.y1 - f.y0) * k) as f32 };
|
||||
}
|
||||
layer
|
||||
}
|
||||
|
||||
/// Straight RGBA8 pixels of an image (decoding an embedded file), with its size.
|
||||
fn pixels(&mut self, img: &af::Image) -> Option<(u32, u32, Vec<u8>)> {
|
||||
match &img.pixels {
|
||||
Pixels::Rgba8(v) => Some((img.width, img.height, v.clone())),
|
||||
Pixels::Encoded(bytes) => {
|
||||
let opts = photocraft_codecs::DecodeOptions {
|
||||
limits: photocraft_codecs::Limits { max_width: 65_535, max_height: 65_535, max_pixels: MAX_PIXELS, max_alloc: 512 << 20 },
|
||||
..Default::default()
|
||||
};
|
||||
match photocraft_codecs::decode_with(bytes, &opts) {
|
||||
Ok(d) => {
|
||||
let (w, h) = d.dimensions();
|
||||
Some((w, h, d.to_rgba8()))
|
||||
}
|
||||
Err(_) => {
|
||||
self.warn("an embedded image that could not be decoded was left out");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn image(&mut self, img: &af::Image, to_doc: Affine) -> Option<LayerContent> {
|
||||
let (w, h, rgba) = self.pixels(img)?;
|
||||
// The pixel grid maps onto the Affinity image's own size, whatever the stored file's.
|
||||
let m = to_doc
|
||||
.mul(&affine(img.transform))
|
||||
.mul(&Affine { m: [f64::from(img.width) / f64::from(w.max(1)), 0.0, 0.0, f64::from(img.height) / f64::from(h.max(1)), 0.0, 0.0] });
|
||||
let surface = self.resample(w, h, &rgba, m, 4)?;
|
||||
Some(match &img.pixels {
|
||||
// A placed image keeps its file, as a smart object placed by the same transform.
|
||||
Pixels::Encoded(bytes) => LayerContent::Smart(SmartObject::new(
|
||||
SmartSource::Embedded { file_name: "Affinity image".into(), bytes: Arc::new(bytes.clone()) },
|
||||
m,
|
||||
Some(surface),
|
||||
)),
|
||||
Pixels::Rgba8(_) => LayerContent::Raster(surface),
|
||||
})
|
||||
}
|
||||
|
||||
fn mask(&mut self, img: &af::Image, to_doc: Affine) -> Option<Surface> {
|
||||
let (w, h, rgba) = self.pixels(img)?;
|
||||
let grey: Vec<u8> = rgba.as_chunks::<4>().0.iter().map(|p| p[0]).collect();
|
||||
let m = to_doc.mul(&affine(img.transform));
|
||||
self.resample(w, h, &grey, m, 1)
|
||||
}
|
||||
|
||||
/// Bilinear resampling of `src` (`w`×`h`, `channels` 4 = straight RGBA8 or 1 = grey) mapped
|
||||
/// by `m` (source pixels to canvas pixels) into a surface; exact copy when `m` is an integer
|
||||
/// translation. Pixels outside the source are transparent (grey: 0, hidden).
|
||||
fn resample(&mut self, w: u32, h: u32, src: &[u8], m: Affine, channels: usize) -> Option<Surface> {
|
||||
let format = if channels == 1 { PixelFormat::GRAY8 } else { self.format };
|
||||
let mut out = if channels == 1 { Surface::with_default(format, &[0.0]) } else { Surface::new(format) };
|
||||
let r = self.bbox(m, af::Rect { x0: 0.0, y0: 0.0, x1: f64::from(w), y1: f64::from(h) });
|
||||
if r.width() == 0 || r.height() == 0 {
|
||||
return Some(out);
|
||||
}
|
||||
if u64::from(r.width()) * u64::from(r.height()) > MAX_PIXELS {
|
||||
self.warn("images larger than 64 megapixels on the canvas were left out");
|
||||
return None;
|
||||
}
|
||||
let [a, b, c, d, e, f] = m.m;
|
||||
let exact = a == 1.0 && d == 1.0 && b == 0.0 && c == 0.0 && e.fract() == 0.0 && f.fract() == 0.0;
|
||||
let inv = m.inverse()?;
|
||||
let (rw, rh) = (r.width() as usize, r.height() as usize);
|
||||
let mut buf = vec![0u8; rw * rh * channels];
|
||||
let at = |x: i64, y: i64, ch: usize| -> f64 {
|
||||
if x < 0 || y < 0 || x >= i64::from(w) || y >= i64::from(h) {
|
||||
return 0.0;
|
||||
}
|
||||
f64::from(src.get((y as usize * w as usize + x as usize) * channels + ch).copied().unwrap_or(0))
|
||||
};
|
||||
for (row, out_row) in buf.chunks_exact_mut(rw * channels).enumerate() {
|
||||
for (col, px) in out_row.chunks_exact_mut(channels).enumerate() {
|
||||
let p = inv.apply(Point::new(f64::from(r.x0) + col as f64 + 0.5, f64::from(r.y0) + row as f64 + 0.5));
|
||||
if exact {
|
||||
let (x, y) = (p.x.floor() as i64, p.y.floor() as i64);
|
||||
for (ch, v) in px.iter_mut().enumerate() {
|
||||
*v = at(x, y, ch) as u8;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let (fx, fy) = (p.x - 0.5, p.y - 0.5);
|
||||
let (x0, y0) = (fx.floor() as i64, fy.floor() as i64);
|
||||
let (tx, ty) = (fx - x0 as f64, fy - y0 as f64);
|
||||
let weights =
|
||||
[((x0, y0), (1.0 - tx) * (1.0 - ty)), ((x0 + 1, y0), tx * (1.0 - ty)), ((x0, y0 + 1), (1.0 - tx) * ty), ((x0 + 1, y0 + 1), tx * ty)];
|
||||
if channels == 1 {
|
||||
px[0] = weights.iter().map(|((x, y), wgt)| at(*x, *y, 0) * wgt).sum::<f64>().round().clamp(0.0, 255.0) as u8;
|
||||
} else {
|
||||
// Interpolate premultiplied colour so transparent pixels don't bleed.
|
||||
let alpha: f64 = weights.iter().map(|((x, y), wgt)| at(*x, *y, 3) * wgt).sum();
|
||||
for (ch, v) in px.iter_mut().enumerate().take(3) {
|
||||
let pm: f64 = weights.iter().map(|((x, y), wgt)| at(*x, *y, ch) * at(*x, *y, 3) / 255.0 * wgt).sum();
|
||||
*v = if alpha > 0.0 { (pm * 255.0 / alpha).round().clamp(0.0, 255.0) as u8 } else { 0 };
|
||||
}
|
||||
px[3] = alpha.round().clamp(0.0, 255.0) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
out.write_interleaved(r, &buf);
|
||||
out.prune();
|
||||
Some(out)
|
||||
}
|
||||
}
|
||||
|
||||
fn default_name(k: &Kind) -> String {
|
||||
match k {
|
||||
Kind::Layer => "Layer",
|
||||
Kind::Group => "Group",
|
||||
Kind::Artboard { .. } => "Artboard",
|
||||
Kind::Shape { .. } => "Shape",
|
||||
Kind::Text(_) => "Text",
|
||||
Kind::Image(_) => "Image",
|
||||
Kind::Unsupported => "Group",
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
fn color(c: ap::Color) -> Color {
|
||||
let f = |v: f64| v.clamp(0.0, 1.0) as f32;
|
||||
match c {
|
||||
ap::Color::Rgb { r, g, b, a } => Color::rgba(f(r), f(g), f(b), f(a)),
|
||||
ap::Color::Cmyk { c, m, y, k, a } => {
|
||||
let cmyk = Color { mode: ColorMode::Cmyk, c: [f(c), f(m), f(y), f(k)], alpha: f(a) };
|
||||
let [r, g, b] = cmyk.to_rgb();
|
||||
Color::rgba(r, g, b, f(a))
|
||||
}
|
||||
ap::Color::Gray { v, a } => Color::rgba(f(v), f(v), f(v), f(a)),
|
||||
ap::Color::Lab { l, a, b, alpha } => {
|
||||
let lab = Color { mode: ColorMode::Lab, c: [f(l / 100.0), f((a + 128.0) / 255.0), f((b + 128.0) / 255.0), 0.0], alpha: f(alpha) };
|
||||
let [r, g, bl] = lab.to_rgb();
|
||||
Color::rgba(r, g, bl, f(alpha))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -229,8 +229,14 @@ pub fn notes_from_blocks(doc: &Document) -> Vec<Note> {
|
||||
doc.metadata.psd_global_blocks.iter().find(|(_, k, _)| *k == ANNO).and_then(|(_, _, d)| parse_anno(d)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Global blocks to write: the raw `Anno` block while it still describes `doc.notes`, else a
|
||||
/// regenerated one in its place (appended when new, dropped when the last note is deleted).
|
||||
/// Whether the document holds an `Anno` block that doesn't parse (its notes can't be shown).
|
||||
pub fn anno_unreadable(doc: &Document) -> bool {
|
||||
doc.metadata.psd_global_blocks.iter().find(|(_, k, _)| *k == ANNO).is_some_and(|(_, _, d)| parse_entries(d).is_none())
|
||||
}
|
||||
|
||||
/// Global blocks to write: the raw `Anno` block while it still describes `doc.notes` (or doesn't
|
||||
/// parse and no note was added), else a regenerated one in its place (appended when new, dropped
|
||||
/// when the last note is deleted).
|
||||
pub fn export_blocks(doc: &Document, mut blocks: Vec<photocraft_doc::PsdGlobalBlock>) -> Vec<photocraft_doc::PsdGlobalBlock> {
|
||||
let at = blocks.iter().position(|(_, k, _)| *k == ANNO);
|
||||
let raw = at.map(|i| blocks[i].2.clone());
|
||||
@@ -246,7 +252,9 @@ pub fn export_blocks(doc: &Document, mut blocks: Vec<photocraft_doc::PsdGlobalBl
|
||||
Note { color: y.color, ..x.clone() } == *y && cx.iter().zip(cy).all(|(p, q)| (p - q).abs() < 1.0 / 512.0)
|
||||
})
|
||||
};
|
||||
if parsed.as_deref().zip(want.as_deref()).is_some_and(|(p, w)| same(p, w)) || (raw.is_none() && doc.notes.is_empty()) {
|
||||
// With no notes, a block we can't read is kept as is: it isn't "the last note deleted", and
|
||||
// dropping it would delete the annotations it holds (issue #944).
|
||||
if parsed.as_deref().zip(want.as_deref()).is_some_and(|(p, w)| same(p, w)) || (entries.is_none() && doc.notes.is_empty()) {
|
||||
return blocks;
|
||||
}
|
||||
let extra: Vec<Vec<u8>> = entries.unwrap_or_default().into_iter().filter_map(|e| if let Entry::Raw(r) = e { Some(r) } else { None }).collect();
|
||||
@@ -395,6 +403,25 @@ mod tests {
|
||||
assert!(parse_anno(&[0, 2, 0, 1, 0, 0, 0, 9]).is_none());
|
||||
}
|
||||
|
||||
/// Issue #944: an `Anno` block that doesn't parse was taken for "no notes" and dropped on save.
|
||||
#[test]
|
||||
fn export_blocks_keeps_an_unreadable_block() {
|
||||
let mut d = Document::new("n", photocraft_doc::Size::new(10, 10), photocraft_doc::ColorMode::Rgb, photocraft_doc::SampleType::U8);
|
||||
let bad: Vec<photocraft_doc::PsdGlobalBlock> = vec![(*b"8BIM", ANNO, Arc::new(vec![0, 2, 0, 1, 0, 0, 0, 9]))];
|
||||
d.metadata.psd_global_blocks = bad.clone();
|
||||
assert!(anno_unreadable(&d) && notes_from_blocks(&d).is_empty());
|
||||
let out = export_blocks(&d, bad.clone());
|
||||
assert_eq!(out.len(), 1);
|
||||
assert!(Arc::ptr_eq(&out[0].2, &bad[0].2));
|
||||
// A note added by the user still writes a fresh block in its place.
|
||||
d.notes.push(Note { text: "new".into(), ..Default::default() });
|
||||
let out = export_blocks(&d, bad);
|
||||
assert_eq!(parse_anno(&out[0].2).unwrap()[0].text, "new");
|
||||
// A readable block is not reported.
|
||||
d.metadata.psd_global_blocks = out;
|
||||
assert!(!anno_unreadable(&d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_resource_roundtrip_and_keep_rules() {
|
||||
let s = MeasurementScale { pixel_length: 150.0, logical_length: 2.0, units: "mm".into() };
|
||||
|
||||
@@ -103,7 +103,7 @@ pub(crate) fn image_to_document(name: &str, img: &Image) -> Result<ImportResult,
|
||||
if !img.meta.text.is_empty() {
|
||||
warnings.push(format!("{} text metadata entries are not kept in the document", img.meta.text.len()));
|
||||
}
|
||||
Ok(ImportResult { document: doc, warnings })
|
||||
Ok(ImportResult { document: doc, warnings, source_read_only: false, preview_only: false })
|
||||
}
|
||||
|
||||
/// `Some(surface)` when the document is exactly one visible, unmasked,
|
||||
|
||||
+18
-3
@@ -15,6 +15,9 @@
|
||||
//! layer; unsupported raw variants fall back to the embedded JPEG preview.
|
||||
//! * Layered TIFFs (Photoshop layer data in tags 37724 and 34377) open with their
|
||||
//! layers through the PSD path and are written back the same way; see `tiff_layers`.
|
||||
//! * Affinity documents (`.af`, `.afdesign`, `.afphoto`, `.afpub`) open natively
|
||||
//! with no source save path, what isn't imported listed in the warnings; a file
|
||||
//! whose native data can't be read opens as its embedded preview; see `affinity`.
|
||||
//! * Every other format goes through `photocraft-codecs` as a single
|
||||
//! "Background" layer (depth and Gray/RGB/CMYK model preserved).
|
||||
//!
|
||||
@@ -25,6 +28,7 @@
|
||||
|
||||
pub mod abr_map;
|
||||
mod adjust_map;
|
||||
pub mod affinity;
|
||||
pub mod annotations_map;
|
||||
pub mod blocks;
|
||||
mod channel_map;
|
||||
@@ -92,6 +96,11 @@ pub struct ImportResult {
|
||||
pub document: Document,
|
||||
/// Human-readable notes about anything approximated or dropped.
|
||||
pub warnings: Vec<String>,
|
||||
/// Save must not write back to the source (an Affinity document: PhotoCraft can't write it).
|
||||
pub source_read_only: 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 and other auxiliary imports refuse it.
|
||||
pub preview_only: bool,
|
||||
}
|
||||
|
||||
/// The import note for a file whose horizontal and vertical resolutions (`x`, `y`, in pixels per
|
||||
@@ -152,7 +161,7 @@ pub fn is_psd(bytes: &[u8]) -> bool {
|
||||
bytes.starts_with(b"8BPS")
|
||||
}
|
||||
|
||||
/// Imports a file. PSD/PSB and camera raws are detected by magic; everything
|
||||
/// Imports a file. PSD/PSB, Affinity containers and camera raws are detected by magic; everything
|
||||
/// else is decoded with `photocraft-codecs`.
|
||||
pub fn import(name: &str, bytes: &[u8]) -> Result<ImportResult, IoError> {
|
||||
import_with(name, bytes, &photocraft_raster::Interrupt::NONE)
|
||||
@@ -171,7 +180,7 @@ pub fn import_with(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt)
|
||||
fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt) -> Result<ImportResult, IoError> {
|
||||
// A declared native extension must reach its loader so malformed bundles retain format errors.
|
||||
if has_extension(name, photocraft_format::EXTENSION) || photocraft_format::is_pcraft(bytes) {
|
||||
return Ok(ImportResult { document: photocraft_format::load_from_bytes(bytes)?, warnings: Vec::new() });
|
||||
return Ok(ImportResult { document: photocraft_format::load_from_bytes(bytes)?, warnings: Vec::new(), source_read_only: false, preview_only: false });
|
||||
}
|
||||
if is_psd(bytes) {
|
||||
let file = PsdFile::from_bytes(bytes)?;
|
||||
@@ -185,7 +194,10 @@ fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt) -
|
||||
let (mut document, warnings) = psd_import::psd_to_document_with(&file, ctl).ok_or(IoError::Cancelled)?;
|
||||
document.name = name.to_string();
|
||||
text_import::prepare(&mut document);
|
||||
return Ok(ImportResult { document, warnings });
|
||||
return Ok(ImportResult { document, warnings, source_read_only: false, preview_only: false });
|
||||
}
|
||||
if affinity::is_affinity(bytes) || affinity::has_extension(name) {
|
||||
return affinity::import(name, bytes);
|
||||
}
|
||||
if raw::is_raw(bytes) {
|
||||
return raw::import_raw(name, bytes);
|
||||
@@ -208,6 +220,9 @@ fn has_extension(name: &str, expected: &str) -> bool {
|
||||
/// bare extension).
|
||||
pub fn export(doc: &Document, name_or_ext: &str, opts: &ExportOptions) -> Result<ExportResult, IoError> {
|
||||
let ext = extension(name_or_ext);
|
||||
if affinity::EXTENSIONS.contains(&ext.as_str()) {
|
||||
return Err(IoError::Unsupported("Affinity export is not implemented; save a new PSD, PNG or .pcraft copy".into()));
|
||||
}
|
||||
if ext == photocraft_format::EXTENSION {
|
||||
let previews = photocraft_format::SaveOptions {
|
||||
thumbnail: Some(photocraft_compose::thumbnail(doc, 256)),
|
||||
|
||||
@@ -588,6 +588,9 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt)
|
||||
doc.patterns = crate::pattern_map::from_global_blocks(&doc);
|
||||
// Notes (`Anno`) and the measurement scale (resource 1074); raw data stays for verbatim export.
|
||||
doc.notes = crate::annotations_map::notes_from_blocks(&doc);
|
||||
if crate::annotations_map::anno_unreadable(&doc) {
|
||||
warnings.push("the notes (Anno block) could not be read; they are kept unchanged on PSD save unless a note is added".into());
|
||||
}
|
||||
if let Some(scale) = crate::annotations_map::raw_scale(&doc) {
|
||||
doc.measurement.scale = scale;
|
||||
}
|
||||
|
||||
@@ -121,7 +121,7 @@ pub fn import_svg(name: &str, bytes: &[u8]) -> Result<ImportResult, IoError> {
|
||||
if doc.layers.is_empty() {
|
||||
conv.warnings.push("SVG: nothing to draw (an empty drawing)".to_string());
|
||||
}
|
||||
Ok(ImportResult { document: doc, warnings: conv.warnings })
|
||||
Ok(ImportResult { document: doc, warnings: conv.warnings, source_read_only: false, preview_only: false })
|
||||
}
|
||||
|
||||
/// The whole drawing rendered at `scale` (1 = its own size), as a straight-alpha RGBA buffer at
|
||||
|
||||
@@ -114,7 +114,7 @@ pub(crate) fn import_layered(name: &str, img: &Image, layers: &[u8]) -> Result<I
|
||||
if !img.meta.text.is_empty() {
|
||||
warnings.push(format!("{} text metadata entries are not kept in the document", img.meta.text.len()));
|
||||
}
|
||||
Ok(ImportResult { document: doc, warnings })
|
||||
Ok(ImportResult { document: doc, warnings, source_read_only: false, preview_only: false })
|
||||
}
|
||||
|
||||
/// Interleaved native-endian samples → planar big-endian planes (PSD merged-image order),
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
//! Affinity documents: native import (layers, artboards, shapes, paints, text, masks) and the
|
||||
//! embedded preview only as a warned fallback that keeps its pixels and alpha; never written back.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, SampleType};
|
||||
use photocraft_io::{ExportOptions, export, import};
|
||||
use proptest::prelude::*;
|
||||
use std::io::Write as _;
|
||||
|
||||
fn png() -> Vec<u8> {
|
||||
let img = Image::from_raw(2, 1, ChannelLayout::Rgba, SampleType::U8, vec![220, 40, 60, 255, 0, 0, 0, 0]).unwrap();
|
||||
photocraft_codecs::encode(&img, Format::Png, &EncodeOptions::default()).unwrap()
|
||||
}
|
||||
|
||||
fn chunk(kind: &[u8; 4], data: &[u8]) -> Vec<u8> {
|
||||
let mut tagged = kind.to_vec();
|
||||
tagged.extend(data);
|
||||
let mut result = (data.len() as u32).to_be_bytes().to_vec();
|
||||
result.extend(&tagged);
|
||||
result.extend(crc32fast::hash(&tagged).to_be_bytes());
|
||||
result
|
||||
}
|
||||
|
||||
/// A synthetic record envelope, not an Affinity document writer (there is no object graph).
|
||||
fn file(png: &[u8]) -> Vec<u8> {
|
||||
let mut b = vec![0; 72];
|
||||
b[..4].copy_from_slice(photocraft_affinity::MAGIC);
|
||||
b[4..6].copy_from_slice(&12u16.to_le_bytes());
|
||||
b[8..12].copy_from_slice(b"nsrP");
|
||||
b[12..16].copy_from_slice(b"#Inf");
|
||||
b[24..32].copy_from_slice(&72u64.to_le_bytes());
|
||||
b[64..68].copy_from_slice(b"Prot");
|
||||
b.extend(b"\xff\xff\xff\xffThmb");
|
||||
b.extend(1u32.to_le_bytes());
|
||||
b.extend((png.len() as u32 + 13).to_le_bytes());
|
||||
b.extend(29u32.to_le_bytes());
|
||||
b.extend(0u32.to_le_bytes());
|
||||
b.extend((png.len() as u32).to_le_bytes());
|
||||
b.push(1);
|
||||
b.extend(png);
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_is_named_and_warns_at_its_actual_size() {
|
||||
let r = import("art.af", &file(&png())).unwrap();
|
||||
assert_eq!((r.document.size.width, r.document.size.height), (2, 1));
|
||||
assert_eq!(r.document.layers.len(), 1);
|
||||
assert_eq!(r.document.layers[0].name, "Affinity preview");
|
||||
assert!(r.source_read_only && r.preview_only);
|
||||
assert!(r.warnings[0].contains("2×1"));
|
||||
assert!(r.warnings[0].contains("native Affinity document could not be read") && r.warnings[0].contains("no layers"), "{}", r.warnings[0]);
|
||||
let out = export(&r.document, "copy.png", &ExportOptions::default()).unwrap();
|
||||
let img = photocraft_codecs::decode(&out.bytes).unwrap();
|
||||
assert_eq!(img.data(), &[220, 40, 60, 255, 0, 0, 0, 0]);
|
||||
let native = export(&r.document, "copy.pcraft", &ExportOptions::default()).unwrap();
|
||||
let back = import("copy.pcraft", &native.bytes).unwrap();
|
||||
assert_eq!(back.document.size, r.document.size);
|
||||
assert!(!back.source_read_only && !back.preview_only);
|
||||
for ext in photocraft_io::affinity::EXTENSIONS {
|
||||
assert!(export(&r.document, ext, &ExportOptions::default()).unwrap_err().to_string().contains("Affinity export"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn magic_wins_over_the_name_and_renamed_legacy_files_are_rejected() {
|
||||
assert!(import("renamed.png", &file(&png())).unwrap().source_read_only);
|
||||
assert!(import("unknown", &file(&png())).unwrap().source_read_only);
|
||||
assert!(import("fake.af", &png()).is_err());
|
||||
// Affinity 1 and 2 (container versions 8 to 11) store the same preview record.
|
||||
let mut legacy = file(&png());
|
||||
legacy[4..6].copy_from_slice(&10u16.to_le_bytes());
|
||||
assert!(import("legacy.afphoto", &legacy).unwrap().preview_only);
|
||||
legacy[4..6].copy_from_slice(&13u16.to_le_bytes());
|
||||
assert!(import("future.af", &legacy).unwrap_err().to_string().contains("unknown container version"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_decoder_preserves_16_bit_gray_and_alpha() {
|
||||
let img = Image::from_u16(2, 1, ChannelLayout::GrayA, &[12345, 65535, 0, 0]).unwrap();
|
||||
let encoded = photocraft_codecs::encode(&img, Format::Png, &EncodeOptions::default()).unwrap();
|
||||
let r = import("gray.af", &file(&encoded)).unwrap();
|
||||
assert_eq!(r.document.depth, photocraft_color::SampleType::U16);
|
||||
assert_eq!(r.document.mode, photocraft_color::ColorMode::Grayscale);
|
||||
let out = export(&r.document, "copy.png", &ExportOptions::default()).unwrap();
|
||||
let decoded = photocraft_codecs::decode(&out.bytes).unwrap();
|
||||
assert_eq!(decoded.sample_type(), SampleType::U16);
|
||||
assert_eq!(decoded.data(), img.data());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn previewless_current_files_return_an_export_fallback() {
|
||||
let mut bytes = file(&png());
|
||||
bytes[24..32].fill(0);
|
||||
let error = import("no-preview.af", &bytes).unwrap_err().to_string();
|
||||
assert!(error.contains("no embedded preview"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corruption_and_every_truncation_fail_without_a_document() {
|
||||
let b = file(&png());
|
||||
for end in 0..b.len() {
|
||||
assert!(import("bad.af", &b[..end]).is_err(), "{end}");
|
||||
}
|
||||
let mut bad_crc = b.clone();
|
||||
// IHDR's CRC; the envelope reader rejects every PNG chunk's bad CRC.
|
||||
bad_crc[130] ^= 1;
|
||||
assert!(import("bad.af", &bad_crc).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crc_failures_after_idat_cannot_be_tolerated_as_partial_pngs() {
|
||||
let mut bad_iend = file(&png());
|
||||
*bad_iend.last_mut().unwrap() ^= 1;
|
||||
assert!(import("bad-iend.af", &bad_iend).unwrap_err().to_string().contains("PNG chunk checksum"));
|
||||
|
||||
let mut encoded = png();
|
||||
let tagged = b"tEXtnote\0preview";
|
||||
let mut chunk = ((tagged.len() - 4) as u32).to_be_bytes().to_vec();
|
||||
chunk.extend(tagged);
|
||||
chunk.extend(crc32fast::hash(tagged).to_be_bytes());
|
||||
let end = encoded.len() - 12;
|
||||
encoded.splice(end..end, chunk.clone());
|
||||
assert!(import("valid-ancillary.af", &file(&encoded)).is_ok());
|
||||
encoded[end + chunk.len() - 1] ^= 1;
|
||||
assert!(import("bad-ancillary.af", &file(&encoded)).unwrap_err().to_string().contains("PNG chunk checksum"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compressed_metadata_and_apng_fail_before_reaching_the_decoder() {
|
||||
for kind in [b"zTXt", b"iTXt", b"iCCP", b"acTL", b"fcTL", b"fdAT"] {
|
||||
let mut encoded = png();
|
||||
// A tiny declared payload stands in for arbitrarily large inflated metadata. It must
|
||||
// return the format's unsupported error, not be passed to a decompressor.
|
||||
let mut tagged = kind.to_vec();
|
||||
tagged.extend(b"preview\0\0\x78\x9c");
|
||||
let mut chunk = ((tagged.len() - 4) as u32).to_be_bytes().to_vec();
|
||||
chunk.extend(&tagged);
|
||||
chunk.extend(crc32fast::hash(&tagged).to_be_bytes());
|
||||
let end = encoded.len() - 12;
|
||||
encoded.splice(end..end, chunk);
|
||||
let error = import("unsupported.af", &file(&encoded)).unwrap_err().to_string();
|
||||
assert!(error.contains("unsupported Affinity file"), "{kind:?}: {error}");
|
||||
assert!(error.contains("PNG metadata") || error.contains("animated PNG"), "{error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_valid_compressed_text_chunks_cannot_bypass_the_total_memory_budget() {
|
||||
let mut compressor = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::best());
|
||||
for _ in 0..1024 {
|
||||
compressor.write_all(&[b'x'; 1024]).unwrap();
|
||||
}
|
||||
let compressed = compressor.finish().unwrap();
|
||||
assert!(compressed.len() < 2048);
|
||||
let mut tagged = b"zTXtnote\0\0".to_vec();
|
||||
tagged.extend(compressed);
|
||||
let mut chunk = ((tagged.len() - 4) as u32).to_be_bytes().to_vec();
|
||||
chunk.extend(&tagged);
|
||||
chunk.extend(crc32fast::hash(&tagged).to_be_bytes());
|
||||
let mut encoded = png();
|
||||
let end = encoded.len() - 12;
|
||||
// A few MiB of encoded PNG would otherwise carry 1 GiB of aggregate text.
|
||||
encoded.splice(end..end, chunk.repeat(1024));
|
||||
let error = import("text-bomb.af", &file(&encoded)).unwrap_err().to_string();
|
||||
assert!(error.contains("compressed PNG metadata"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_critical_chunks_after_idat_fail_before_png_finish() {
|
||||
let original = png();
|
||||
for trailing in [chunk(b"IHDR", &original[16..29]), chunk(b"PLTE", &[0, 0, 0]), chunk(b"ABCD", &[])] {
|
||||
let mut encoded = original.clone();
|
||||
let end = encoded.len() - 12;
|
||||
encoded.splice(end..end, trailing);
|
||||
let error = import("bad-order.af", &file(&encoded)).unwrap_err().to_string();
|
||||
assert!(error.contains("Affinity file"), "{error}");
|
||||
}
|
||||
let mut encoded = original;
|
||||
let end = encoded.len() - 12;
|
||||
let mut interrupted = chunk(b"tEXt", b"note\0preview");
|
||||
interrupted.extend(chunk(b"IDAT", &[]));
|
||||
encoded.splice(end..end, interrupted);
|
||||
assert!(import("split-idat.af", &file(&encoded)).unwrap_err().to_string().contains("non-contiguous PNG image data"));
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(128))]
|
||||
#[test]
|
||||
fn preview_and_decoder_survive_mutations(edits in prop::collection::vec((0usize..300, any::<u8>()), 0..16)) {
|
||||
let mut b = file(&png());
|
||||
for (i, v) in edits { if let Some(byte) = b.get_mut(i) { *byte = v; } }
|
||||
let _ = import("mutated.af", &b);
|
||||
}
|
||||
}
|
||||
|
||||
mod native {
|
||||
use super::*;
|
||||
use photocraft_affinity::synth::{self, F, Method, tag};
|
||||
use photocraft_doc::LayerContent;
|
||||
|
||||
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) -> Vec<u8> {
|
||||
let mut r = x.to_le_bytes().to_vec();
|
||||
r.extend(y.to_le_bytes());
|
||||
r.extend([1, 0]);
|
||||
r
|
||||
}
|
||||
|
||||
/// A version-12 document at 144 ppi, 200×100: an artboard "Board" moved by (20, 10) holding
|
||||
/// a red ring (two subpaths, filled even-odd) with a 4 px blue stroke.
|
||||
pub(super) fn document(method: Method) -> Vec<u8> {
|
||||
let square = |s: f64, o: f64| vec![node(o, o), node(o + s, o), node(o + s, o + s), node(o, o + s), node(o, o)];
|
||||
let ring = F::Def(
|
||||
20,
|
||||
vec![tag(b"PCrv")],
|
||||
vec![
|
||||
(tag(b"Desc"), F::Str("Ring".into())),
|
||||
(
|
||||
tag(b"Crvs"),
|
||||
F::Obj(
|
||||
tag(b"PCvD"),
|
||||
vec![(
|
||||
tag(b"Data"),
|
||||
F::Pos(vec![
|
||||
F::U8(0),
|
||||
F::U32(2),
|
||||
F::Bool(true),
|
||||
F::Records(18, square(60.0, 0.0)),
|
||||
F::Bool(true),
|
||||
F::Records(18, square(20.0, 20.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))]))])]),
|
||||
),
|
||||
],
|
||||
);
|
||||
let board = F::Def(
|
||||
10,
|
||||
vec![tag(b"ShpN")],
|
||||
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, 120.0, 80.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![ring])),
|
||||
],
|
||||
);
|
||||
let spread = F::Def(
|
||||
2,
|
||||
vec![tag(b"Sprd")],
|
||||
vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 200.0, 100.0])), (tag(b"SprT"), F::Bool(true)), (tag(b"Chld"), F::Shared(vec![board]))],
|
||||
);
|
||||
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 artboards_shapes_and_paints_import_as_editable_layers() {
|
||||
for method in [Method::Stored, Method::Zlib, Method::Zstd] {
|
||||
let r = import("native.af", &document(method)).unwrap();
|
||||
assert!(r.source_read_only && !r.preview_only, "{:?}", r.warnings);
|
||||
let d = &r.document;
|
||||
assert_eq!((d.size.width, d.size.height, d.resolution_dpi), (200, 100, 144.0));
|
||||
let board = &d.layers[0];
|
||||
assert_eq!(board.name, "Board");
|
||||
let LayerContent::Group(g) = &board.content else { panic!("{:?}", board.content) };
|
||||
let ab = g.artboard.as_ref().unwrap();
|
||||
assert_eq!((ab.rect.x0, ab.rect.y0, ab.rect.x1, ab.rect.y1), (20, 10, 140, 90));
|
||||
let ring = &g.children[0];
|
||||
assert_eq!(ring.name, "Ring");
|
||||
let LayerContent::Shape(sh) = &ring.content else { panic!() };
|
||||
assert_eq!(sh.path.subpaths.len(), 2);
|
||||
assert_eq!(sh.path.subpaths[0].knots.len(), 4, "the closing duplicate knot is merged");
|
||||
assert_eq!(sh.path.subpaths[0].knots[0].anchor, photocraft_geom::Point::new(20.0, 10.0));
|
||||
assert_eq!(sh.stroke.as_ref().unwrap().width, 4.0);
|
||||
// The cache shows the ring: red on it, empty in its hole.
|
||||
let cache = sh.cache.as_ref().unwrap();
|
||||
let px = |x: i32, y: i32| cache.to_interleaved(photocraft_geom::Rect::new(x, y, x + 1, y + 1));
|
||||
assert_eq!(px(30, 20), [255, 0, 0, 255]);
|
||||
assert_eq!(px(50, 40)[3], 0, "even-odd hole");
|
||||
// Saved as a native PhotoCraft document and opened again.
|
||||
let saved = export(d, "copy.pcraft", &ExportOptions::default()).unwrap();
|
||||
let back = import("copy.pcraft", &saved.bytes).unwrap();
|
||||
assert_eq!(back.document.layers.len(), d.layers.len());
|
||||
assert!(export(d, "copy.psd", &ExportOptions::default()).is_ok());
|
||||
assert!(export(d, "copy.af", &ExportOptions::default()).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damaged_native_document_falls_back_to_its_preview_with_the_reason() {
|
||||
let mut bytes = document(Method::Zlib);
|
||||
bytes[80] ^= 0xFF;
|
||||
let r = import("broken.af", &bytes).unwrap();
|
||||
assert!(r.preview_only);
|
||||
assert!(r.warnings[0].contains("could not be read (damaged Affinity file"), "{}", r.warnings[0]);
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(256))]
|
||||
#[test]
|
||||
fn mutated_native_documents_never_panic(edits in prop::collection::vec((0usize..2048, any::<u8>()), 1..16), stored in any::<bool>()) {
|
||||
let mut b = document(if stored { Method::Stored } else { Method::Zstd });
|
||||
let n = b.len();
|
||||
for (i, v) in edits { b[i % n] = v; }
|
||||
let _ = import("mutated.af", &b);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
//! Public Affinity documents (`cargo xtask corpus --affinity`, pinned and sha256-verified) open
|
||||
//! natively and look 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 smaller of the two sizes) must stay under its ceiling,
|
||||
//! set just above the difference measured when the importer landed.
|
||||
#![cfg(feature = "corpus")]
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use photocraft_io::{ExportOptions, export, import};
|
||||
|
||||
/// (file, ceiling). Measured differences when the importer landed are in the comments.
|
||||
const FILES: &[(&str, f64)] = &[
|
||||
("vector-art/mexican-guy.af", 3e+00), // 1.92: brush strokes as plain strokes
|
||||
("vector-art/mexican-man.af", 4e+00), // 2.74
|
||||
("vector-art/mexican-woman.af", 4e+00), // 2.24
|
||||
("vector-art/playing-cards.af", 4e+00), // 3.04: layer effects
|
||||
("vector-art/beer.afdesign", 4e+00), // 2.91
|
||||
("vector-art/cactus.afdesign", 2e+00), // 0.57
|
||||
("vector-art/car.afdesign", 2e+00), // 0.39
|
||||
("vector-art/flowers.afdesign", 3e+00), // 2.09
|
||||
("vector-art/lion-track.afdesign", 6e+00), // 4.41
|
||||
("afdesignload/color.afdesign", 1e+00), // 0.00
|
||||
("afdesignload/layer_mode.afdesign", 1e+00), // 0.00
|
||||
("afdesignload/layer_test.afdesign", 1e+00), // 0.00
|
||||
("afdesignload/margins.afdesign", 1e+00), // 0.00
|
||||
("afdesignload/raster_test.afdesign", 2e+00), // 0.82: pixel layer
|
||||
("afdesignload/revision_test.afdesign", 1e+00), // 0.01
|
||||
("afdesignload/shape_test.afdesign", 6e+00), // 4.60: cloud and heart shapes as ellipses
|
||||
("afdesignload/slice_test.afdesign", 1e+00), // 0.02
|
||||
("afdesignload/test_path.afdesign", 2e+00), // 1.37
|
||||
("jac21/SimpleLogo.afdesign", 2e+00), // 0.78
|
||||
("jac21/SimpleLogoBanner.afdesign", 4e+00), // 2.72: placed images in a Display P3 document
|
||||
("asset-store-template/AssetStore.aftemplate", 1e+00), // 0.00: artboards
|
||||
];
|
||||
|
||||
fn corpus(rel: &str) -> Vec<u8> {
|
||||
let p = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../corpus/affinity").join(rel);
|
||||
std::fs::read(&p).unwrap_or_else(|e| panic!("{}: {e}: run `cargo xtask corpus --affinity`", p.display()))
|
||||
}
|
||||
|
||||
/// Straight RGBA8 pixels flattened on white.
|
||||
fn on_white(px: &[u8]) -> Vec<f64> {
|
||||
px.chunks_exact(4)
|
||||
.flat_map(|p| {
|
||||
let a = f64::from(p[3]) / 255.0;
|
||||
[0, 1, 2].map(|i| f64::from(p[i]) * a + 255.0 * (1.0 - a))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Resample `src` (`sw`×`sh`, 3 channels) to `dw`×`dh`: each target pixel averages the source
|
||||
/// pixels it covers (at least one, so small documents scale up too).
|
||||
fn shrink(src: &[f64], sw: usize, sh: usize, dw: usize, dh: usize) -> Vec<f64> {
|
||||
let span = |d: usize, s: usize, n: usize| {
|
||||
let a = (d * s / n).min(s - 1);
|
||||
(a, ((d + 1) * s).div_ceil(n).clamp(a + 1, s))
|
||||
};
|
||||
let mut out = Vec::with_capacity(dw * dh * 3);
|
||||
for ty in 0..dh {
|
||||
let (y0, y1) = span(ty, sh, dh);
|
||||
for tx in 0..dw {
|
||||
let (x0, x1) = span(tx, sw, dw);
|
||||
let mut sum = [0.0f64; 3];
|
||||
for y in y0..y1 {
|
||||
for x in x0..x1 {
|
||||
for (c, s) in sum.iter_mut().enumerate() {
|
||||
*s += src[(y * sw + x) * 3 + c];
|
||||
}
|
||||
}
|
||||
}
|
||||
let n = ((y1 - y0) * (x1 - x0)) as f64;
|
||||
out.extend(sum.map(|s| s / n));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_affinity_documents_look_like_their_affinity_thumbnails() {
|
||||
let mut failures = Vec::new();
|
||||
for (name, ceiling) in FILES {
|
||||
let bytes = corpus(name);
|
||||
let r = import(name, &bytes).unwrap();
|
||||
assert!(!r.preview_only, "{name} opened as its preview: {:?}", r.warnings);
|
||||
let thumb = photocraft_codecs::decode(photocraft_affinity::preview(&bytes).unwrap().png).unwrap();
|
||||
let (tw, th) = thumb.dimensions();
|
||||
let png = export(&r.document, "x.png", &ExportOptions::default()).unwrap().bytes;
|
||||
let flat = photocraft_codecs::decode(&png).unwrap();
|
||||
let (fw, fh) = flat.dimensions();
|
||||
// Several pages sit side by side; the thumbnail shows the first.
|
||||
let fw_first = ((f64::from(fh) * f64::from(tw) / f64::from(th)).round() as u32).min(fw);
|
||||
let rgba = flat.to_rgba8();
|
||||
let cropped: Vec<u8> = rgba.chunks_exact(fw as usize * 4).flat_map(|row| row[..fw_first as usize * 4].to_vec()).collect();
|
||||
// Compare at the smaller of the two sizes, so neither picture is enlarged.
|
||||
let (cw, ch) = if fh < th { (fw_first, fh) } else { (tw, th) };
|
||||
let got = shrink(&on_white(&cropped), fw_first as usize, fh as usize, cw as usize, ch as usize);
|
||||
let want = shrink(&on_white(&thumb.to_rgba8()), tw as usize, th as usize, cw as usize, ch as usize);
|
||||
let diff = got.iter().zip(&want).map(|(a, b)| (a - b).abs()).sum::<f64>() / want.len() as f64;
|
||||
eprintln!("{name}: {diff:.2} (ceiling {ceiling})");
|
||||
if diff > *ceiling {
|
||||
failures.push(format!("{name}: {diff:.2} > {ceiling}"));
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
}
|
||||
@@ -147,3 +147,21 @@ fn a_note_icon_near_i32_max_saves_with_representable_geometry() {
|
||||
let back = import("x.psd", &to_psd(&doc)).unwrap().document;
|
||||
assert_eq!(back.notes[0].position, [42.0, 28.0]);
|
||||
}
|
||||
|
||||
/// Issue #944: an `Anno` block the reader rejects opened as "no notes" without a word, and the
|
||||
/// next save deleted it. It now opens with a warning and is written back byte for byte.
|
||||
#[test]
|
||||
fn an_unreadable_anno_block_warns_and_survives_a_save() {
|
||||
// The block declares two annotations but holds one.
|
||||
let mut broken = anno_with_icon(28, 42);
|
||||
broken[4..8].copy_from_slice(&2u32.to_be_bytes());
|
||||
let imp = import("notes.psd", &psd_with_anno(broken.clone())).expect("import");
|
||||
assert!(imp.document.notes.is_empty());
|
||||
assert!(imp.warnings.iter().any(|w| w.contains("Anno")), "{:?}", imp.warnings);
|
||||
let f = PsdFile::from_bytes(&to_psd(&imp.document)).unwrap();
|
||||
assert_eq!(f.global_block(b"Anno").expect("Anno block kept").data, broken);
|
||||
// Control: the intact block reads with no warning.
|
||||
let ok = import("notes.psd", &psd_with_anno(anno_with_icon(28, 42))).expect("import");
|
||||
assert_eq!(ok.document.notes.len(), 1);
|
||||
assert!(!ok.warnings.iter().any(|w| w.contains("Anno")));
|
||||
}
|
||||
+23
-1
@@ -1056,7 +1056,11 @@ pub mod transcode {
|
||||
Ok(())
|
||||
})?;
|
||||
mask_heights = mh;
|
||||
t.sized_region(false, |x| x.u32s_to_end())?;
|
||||
// Blending ranges are bytes (black low/high, white low/high per channel), so
|
||||
// Photoshop stores them in the same order in either byte order: an Intel-order
|
||||
// layered TIFF holds the default ranges as `00 00 ff ff`. Swapping them as u32s
|
||||
// turned every range into `ff ff 00 00`, which hides every pixel of the layer.
|
||||
t.sized_region(false, |x| x.rest())?;
|
||||
t.pascal(4)?;
|
||||
t.blocks()
|
||||
})?;
|
||||
@@ -2223,6 +2227,24 @@ mod tests {
|
||||
assert!(little.windows(table + samples.len()).any(|w| w[..table] == swapped[..] && w[table..] == *samples));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blending_ranges_are_bytes_in_either_order() {
|
||||
let mut file = testgen::layered(Version::Psd, ColorMode::Rgb, 8, Compression::Rle);
|
||||
let ranges: Vec<u8> = [0u8, 0, 255, 255].repeat(10);
|
||||
let marked: Vec<u8> = (1u8..=40).collect();
|
||||
for (i, l) in file.layer_info.as_mut().unwrap().layers.iter_mut().enumerate() {
|
||||
l.blending_ranges.data = if i == 0 { marked.clone() } else { ranges.clone() };
|
||||
}
|
||||
let d = ImageSourceData::from_psd(&file);
|
||||
let (little, _) = d.to_bytes(ByteOrder::Little).unwrap();
|
||||
// Written verbatim (what Photoshop writes in an `II` TIFF), not swapped per u32.
|
||||
assert!(little.windows(marked.len()).any(|w| w == marked));
|
||||
let (back, _) = ImageSourceData::from_bytes(&little).unwrap();
|
||||
for (a, b) in d.layer_info.as_ref().unwrap().layers.iter().zip(&back.layer_info.as_ref().unwrap().layers) {
|
||||
assert_eq!(a.blending_ranges, b.blending_ranges);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn psb_sized_data_uses_long_lengths_and_counts() {
|
||||
let file = testgen::layered(Version::Psb, ColorMode::Cmyk, 8, Compression::Rle);
|
||||
|
||||
@@ -11,6 +11,8 @@ default = []
|
||||
# Synthetic raw-file generators (DNG / CR2 / TIFF-EP writers and a lossless-JPEG
|
||||
# encoder) for tests and fuzz seeds.
|
||||
testgen = []
|
||||
# Real-file corpus tests (opt-in; `cargo xtask test-corpus`), defined with the corpus PR.
|
||||
corpus = []
|
||||
|
||||
[dependencies]
|
||||
|
||||
|
||||
+82
-3
@@ -116,11 +116,18 @@ pub(crate) fn maker_note(t: &Tiff, ifds: &[Ifd]) -> Option<Ifd> {
|
||||
/// As-shot white-balance multipliers (R, G, B) from ColorData.
|
||||
fn as_shot_wb(t: &Tiff, mn: &Ifd) -> Option<[f64; 3]> {
|
||||
let e = mn.get(CANON_COLOR_DATA)?;
|
||||
let v = t.uints(e);
|
||||
// The entry is UNDEFINED: its bytes are a table of 16-bit words (file byte order), and the
|
||||
// offsets below count words, not bytes. Reads stay inside the entry's own byte count.
|
||||
let bytes = e.unit().checked_mul(e.count as usize)?;
|
||||
let word = |off: usize| {
|
||||
let at = off.checked_mul(2)?.checked_add(e.at)?;
|
||||
(at.checked_add(2)? <= e.at.checked_add(bytes)?).then(|| t.u16_at(at)).flatten().map(f64::from)
|
||||
};
|
||||
// Word offsets of WB_RGGBLevelsAsShot used by the ColorData versions.
|
||||
for off in [0x3F, 0x47, 0x19, 0x22] {
|
||||
let Some(l) = v.get(off..off + 4) else { continue };
|
||||
let [r, g1, g2, b] = [l[0], l[1], l[2], l[3]].map(f64::from);
|
||||
let (Some(r), Some(g1), Some(g2), Some(b)) = (word(off), word(off + 1), word(off + 2), word(off + 3)) else {
|
||||
continue;
|
||||
};
|
||||
let g = (g1 + g2) / 2.0;
|
||||
let plausible = (256.0..16384.0).contains(&g) && (g1 - g2).abs() <= g * 0.05 && (0.25..8.0).contains(&(r / g)) && (0.25..8.0).contains(&(b / g));
|
||||
if plausible {
|
||||
@@ -345,3 +352,75 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(all(test, feature = "corpus"))]
|
||||
mod wb_debug {
|
||||
|
||||
/// The corpus PowerShot CR2: ColorData's WB_RGGBLevelsAsShot sits at word offset 0x47 (the
|
||||
/// PowerShot SX50 HS's ColorData version), the same words its DNG conversion neutralises
|
||||
/// (AsShotNeutral -> WB ≈ [1.66, 1.0, 1.74]; the ColorData words give [1.70, 1.0, 1.69] —
|
||||
/// the converter re-derives them through the color matrix). The synthetic twin of this
|
||||
/// oracle is `color_data_wb_as_words` above; it runs in every CI.
|
||||
#[test]
|
||||
fn power_shot_color_data_holds_the_as_shot_wb() {
|
||||
let Ok(bytes) = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../../corpus/pixls/IMG_4059.CR2")) else {
|
||||
panic!("corpus/pixls is fetched for corpus runs: cargo xtask corpus --pixls");
|
||||
};
|
||||
let s = crate::decode(&bytes, &crate::Limits::default()).expect("decode");
|
||||
let wb = s.camera_wb.expect("the PowerShot's as-shot WB is read now");
|
||||
assert!((wb[0] - 1.696).abs() < 0.01 && wb[1] == 1.0 && (wb[2] - 1.688).abs() < 0.01, "{wb:?}");
|
||||
// The file's white balance is used, not an estimate: no grey-world note in the develop.
|
||||
let d = crate::develop_sensor(&s, &crate::DevelopOptions::default()).expect("develop");
|
||||
assert!(!d.warnings.iter().any(|w| w.contains("estimated automatically")), "{:?}", d.warnings);
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod wb_words {
|
||||
use crate::testgen::Cr2Spec;
|
||||
|
||||
/// ColorData arrives as UNDEFINED bytes that are 16-bit words; the offsets count words.
|
||||
/// Regression for the byte-scaled lookup that never found the balance (synthetic twin of
|
||||
/// the corpus oracle, runs in every CI).
|
||||
#[test]
|
||||
fn color_data_wb_as_words() {
|
||||
let spec = Cr2Spec {
|
||||
width: 8,
|
||||
height: 8,
|
||||
data: vec![2048u16; 64],
|
||||
precision: 12,
|
||||
components: 1,
|
||||
slices: Vec::new(),
|
||||
borders: None,
|
||||
wb_rggb: Some([1613, 951, 951, 1605]),
|
||||
orientation: 1,
|
||||
model_id: None,
|
||||
};
|
||||
let bytes = spec.build();
|
||||
let s = crate::decode(&bytes, &crate::Limits::default()).expect("decode");
|
||||
let wb = s.camera_wb.expect("the synthetic as-shot WB is read");
|
||||
assert!((wb[0] - 1613.0 / 951.0).abs() < 1e-9 && wb[1] == 1.0 && (wb[2] - 1605.0 / 951.0).abs() < 1e-9, "{wb:?}");
|
||||
}
|
||||
|
||||
/// A ColorData entry too short for the offsets reads no balance instead of running past it.
|
||||
#[test]
|
||||
fn short_color_data_reads_no_wb() {
|
||||
let spec = Cr2Spec { wb_rggb: None, ..minimal_spec() };
|
||||
let bytes = spec.build();
|
||||
let s = crate::decode(&bytes, &crate::Limits::default()).expect("decode");
|
||||
assert!(s.camera_wb.is_none());
|
||||
}
|
||||
|
||||
fn minimal_spec() -> Cr2Spec {
|
||||
Cr2Spec {
|
||||
width: 8,
|
||||
height: 8,
|
||||
data: vec![2048u16; 64],
|
||||
precision: 12,
|
||||
components: 1,
|
||||
slices: Vec::new(),
|
||||
borders: None,
|
||||
wb_rggb: None,
|
||||
orientation: 1,
|
||||
model_id: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -663,7 +663,7 @@ pub struct Cr2Spec {
|
||||
pub slices: Vec<usize>,
|
||||
/// Image borders: left, top, right, bottom (inclusive), as in SensorInfo.
|
||||
pub borders: Option<[u16; 4]>,
|
||||
/// As-shot RGGB levels written to ColorData at word offset 0x3F.
|
||||
/// As-shot RGGB levels written to ColorData at word offset 0x47 (the PowerShot version).
|
||||
pub wb_rggb: Option<[u16; 4]>,
|
||||
pub orientation: u16,
|
||||
/// Canon model ID written to MakerNote tag 0x0010.
|
||||
@@ -703,9 +703,14 @@ impl Cr2Spec {
|
||||
mn.push((0x00E0, Val::Short(vec![34, w as u16, h as u16, 0, 0, l, tp, r, b, 0, 0, 0, 0, 0, 0, 0, 0])));
|
||||
}
|
||||
if let Some(wb) = self.wb_rggb {
|
||||
let mut cd = vec![0u16; 1273];
|
||||
cd[0x3F..0x43].copy_from_slice(&wb);
|
||||
mn.push((0x4001, Val::Short(cd)));
|
||||
// Canon writes ColorData (0x4001) as UNDEFINED bytes that are 16-bit words; the
|
||||
// WB_RGGBLevelsAsShot of the PowerShot version sits at word offset 0x47 (the EOS
|
||||
// versions use 0x3F — see as_shot_wb).
|
||||
let mut cd = vec![0u8; 2 * 1273];
|
||||
for (i, v) in wb.iter().enumerate() {
|
||||
cd[2 * (0x47 + i)..2 * (0x47 + i) + 2].copy_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
mn.push((0x4001, Val::Undefined(cd)));
|
||||
}
|
||||
let mut exif: Vec<(u16, Val)> = vec![(33434, Val::Rational(vec![(1, 100)]))];
|
||||
if !mn.is_empty() {
|
||||
|
||||
@@ -89,7 +89,7 @@ pub(crate) struct Entry {
|
||||
|
||||
impl Entry {
|
||||
/// Size in bytes of one value of this entry's type (0 = unknown type).
|
||||
fn unit(&self) -> usize {
|
||||
pub(crate) fn unit(&self) -> usize {
|
||||
type_size(self.typ)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use parley::FontContext;
|
||||
use parley::fontique::{Blob, Collection, CollectionOptions, FontStyle, GenericFamily, SourceCache};
|
||||
use skrifa::raw::FileRef;
|
||||
use parley::fontique::{Blob, Collection, CollectionOptions, FontStyle, FontWeight, FontWidth, GenericFamily, SourceCache};
|
||||
use skrifa::raw::{FileRef, TableProvider, types::Tag};
|
||||
use skrifa::{MetadataProvider, string::StringId};
|
||||
|
||||
/// Family used when a style names no family or an unknown one.
|
||||
@@ -210,6 +210,31 @@ impl FontDb {
|
||||
self.fcx.collection.family_id(name).is_some()
|
||||
}
|
||||
|
||||
/// Whether the face selected for a character style exposes an OpenType GSUB feature.
|
||||
///
|
||||
/// This deliberately checks the matched face, rather than every face in the family: a
|
||||
/// regular face can lack a feature that its italic or bold sibling has (or vice versa).
|
||||
pub(crate) fn selected_face_has_feature(&mut self, family: &str, weight: u16, italic: bool, feature: Tag) -> bool {
|
||||
let family = if family.is_empty() || !self.has_family(family) { DEFAULT_FAMILY } else { family };
|
||||
let Some(info) = self.fcx.collection.family_by_name(family) else {
|
||||
return false;
|
||||
};
|
||||
let style = if italic { FontStyle::Italic } else { FontStyle::Normal };
|
||||
let Some(font) = info.match_font(FontWidth::default(), style, FontWeight::new(weight.clamp(1, 1000) as f32), true) else {
|
||||
return false;
|
||||
};
|
||||
let Some(blob) = font.load(Some(&mut self.fcx.source_cache)) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(font) = skrifa::FontRef::from_index(blob.as_ref(), font.index()) else {
|
||||
return false;
|
||||
};
|
||||
font.gsub()
|
||||
.ok()
|
||||
.and_then(|gsub| gsub.feature_list().ok())
|
||||
.is_some_and(|features| features.feature_records().iter().any(|record| record.feature_tag() == feature))
|
||||
}
|
||||
|
||||
/// Faces of a family (weights, italics, variation axes).
|
||||
pub fn faces(&mut self, family: &str) -> Vec<FaceInfo> {
|
||||
let Some(info) = self.fcx.collection.family_by_name(family) else {
|
||||
|
||||
@@ -23,9 +23,30 @@ use parley::{
|
||||
};
|
||||
use photocraft_doc::TextLayer;
|
||||
use photocraft_doc::text::{Caps, CharStyle, Kerning, Orientation, TextAlign, TextDirection, TextShape};
|
||||
use skrifa::raw::types::Tag;
|
||||
|
||||
use crate::fonts::FontDb;
|
||||
|
||||
/// Photoshop synthesizes small caps for faces without an OpenType `smcp` table. Keep the same
|
||||
/// readable hierarchy for every font instead of silently rendering lowercase text unchanged.
|
||||
const SYNTHETIC_SMALL_CAPS_SCALE: f32 = 0.7;
|
||||
|
||||
/// The rendering path for a `SmallCaps` character style.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum SmallCapsMode {
|
||||
None,
|
||||
OpenType,
|
||||
Synthetic,
|
||||
}
|
||||
|
||||
fn small_caps_mode(caps: Caps, face_has_smcp: bool) -> SmallCapsMode {
|
||||
match caps {
|
||||
Caps::SmallCaps if face_has_smcp => SmallCapsMode::OpenType,
|
||||
Caps::SmallCaps => SmallCapsMode::Synthetic,
|
||||
_ => SmallCapsMode::None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Index of the character run whose style a glyph uses, plus the vertical-type class of its
|
||||
/// characters ([`VClass`] as `u8`; always 0 in horizontal type).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
@@ -449,6 +470,17 @@ impl Layouter {
|
||||
};
|
||||
let mut ptext = String::with_capacity(prefix.len() + content.len());
|
||||
ptext.push_str(prefix);
|
||||
// A real `smcp` substitution must receive lowercase source text; only the fallback
|
||||
// path uppercases it and scales it down.
|
||||
let small_caps: Vec<SmallCapsMode> = out
|
||||
.styles
|
||||
.iter()
|
||||
.map(|style| small_caps_mode(style.caps, fonts.selected_face_has_feature(&style.font_family, style.weight, style.italic, Tag::new(b"smcp"))))
|
||||
.collect();
|
||||
// Ranges in `ptext` for lowercase characters rendered as synthetic small caps.
|
||||
// They have the same UTF-8 length as their uppercase form, so the layer's byte-based
|
||||
// run and caret offsets remain unchanged.
|
||||
let mut synthetic_small_caps: Vec<(Range<usize>, f32)> = Vec::new();
|
||||
for (i, ch) in content.char_indices() {
|
||||
// A forced line break ends the line but not the paragraph. The line breaker knows
|
||||
// it as a newline, which has the same length, so text offsets don't move.
|
||||
@@ -456,11 +488,16 @@ impl Layouter {
|
||||
ptext.push('\n');
|
||||
continue;
|
||||
}
|
||||
let caps = out.styles[style_at(prange.start + i)].caps;
|
||||
if caps == Caps::AllCaps {
|
||||
let style = &out.styles[style_at(prange.start + i)];
|
||||
let caps = style.caps;
|
||||
if caps == Caps::AllCaps || small_caps[style_at(prange.start + i)] == SmallCapsMode::Synthetic {
|
||||
let up: String = ch.to_uppercase().collect();
|
||||
if up.len() == ch.len_utf8() {
|
||||
ptext.push_str(&up);
|
||||
if caps == Caps::SmallCaps && ch.is_lowercase() {
|
||||
let end = ptext.len();
|
||||
synthetic_small_caps.push((end - up.len()..end, style.size_pt * k * SYNTHETIC_SMALL_CAPS_SCALE));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -474,7 +511,7 @@ impl Layouter {
|
||||
// Paragraph-start style as the default (covers the direction mark and empty
|
||||
// paragraphs), then every run piece intersecting this paragraph.
|
||||
let si0 = style_at(prange.start);
|
||||
for p in style_props(&out.styles[si0], k, &fallback, si0 as u32) {
|
||||
for p in style_props(&out.styles[si0], k, &fallback, si0 as u32, small_caps[si0]) {
|
||||
b.push_default(p);
|
||||
}
|
||||
for (ri, st) in out.styles.iter().enumerate() {
|
||||
@@ -484,7 +521,7 @@ impl Layouter {
|
||||
continue;
|
||||
}
|
||||
let range = (a - prange.start + prefix.len())..(z - prange.start + prefix.len());
|
||||
for p in style_props(st, k, &fallback, ri as u32) {
|
||||
for p in style_props(st, k, &fallback, ri as u32, small_caps[ri]) {
|
||||
b.push(p, range.clone());
|
||||
}
|
||||
if vertical {
|
||||
@@ -495,7 +532,7 @@ impl Layouter {
|
||||
if cls == VClass::Rotate || from >= to {
|
||||
return;
|
||||
}
|
||||
let mut feats = feature_list(st);
|
||||
let mut feats = feature_list(st, small_caps[ri]);
|
||||
feats.push("\"vert\" 1".into());
|
||||
let r = (range.start + from)..(range.start + to);
|
||||
b.push(StyleProperty::FontFeatures(FontFeatures::Source(Cow::Owned(feats.join(", ")))), r.clone());
|
||||
@@ -517,6 +554,9 @@ impl Layouter {
|
||||
}
|
||||
}
|
||||
}
|
||||
for (range, size) in synthetic_small_caps {
|
||||
b.push(StyleProperty::FontSize(size), range);
|
||||
}
|
||||
b.build(&ptext)
|
||||
};
|
||||
let indent_start = ps.start_indent_pt * k;
|
||||
@@ -1014,7 +1054,7 @@ impl skrifa::outline::OutlinePen for YMax {
|
||||
}
|
||||
|
||||
/// OpenType feature settings of a character style (CSS `font-feature-settings` items).
|
||||
fn feature_list(st: &CharStyle) -> Vec<String> {
|
||||
fn feature_list(st: &CharStyle, small_caps: SmallCapsMode) -> Vec<String> {
|
||||
let mut feats: Vec<String> = Vec::new();
|
||||
// Optical and manual kerning replace the font's kerning table; a character with a manual
|
||||
// kern is manually kerned whatever its mode (as in Photoshop).
|
||||
@@ -1028,7 +1068,7 @@ fn feature_list(st: &CharStyle) -> Vec<String> {
|
||||
if st.discretionary_ligatures {
|
||||
feats.push("\"dlig\" 1".into());
|
||||
}
|
||||
if st.caps == Caps::SmallCaps {
|
||||
if small_caps == SmallCapsMode::OpenType {
|
||||
feats.push("\"smcp\" 1".into());
|
||||
}
|
||||
for f in &st.features {
|
||||
@@ -1039,7 +1079,7 @@ fn feature_list(st: &CharStyle) -> Vec<String> {
|
||||
feats
|
||||
}
|
||||
|
||||
fn style_props(st: &CharStyle, k: f32, fallback: &[String], idx: u32) -> Vec<StyleProperty<'static, RunBrush>> {
|
||||
fn style_props(st: &CharStyle, k: f32, fallback: &[String], idx: u32, small_caps: SmallCapsMode) -> Vec<StyleProperty<'static, RunBrush>> {
|
||||
let px = (st.size_pt * k).max(0.01);
|
||||
let mut fam: Vec<String> = Vec::new();
|
||||
if !st.font_family.is_empty() {
|
||||
@@ -1052,7 +1092,7 @@ fn style_props(st: &CharStyle, k: f32, fallback: &[String], idx: u32) -> Vec<Sty
|
||||
fam.extend(fallback.iter().map(|f| quote(f)));
|
||||
}
|
||||
fam.push("sans-serif".into());
|
||||
let feats = feature_list(st);
|
||||
let feats = feature_list(st, small_caps);
|
||||
let vars: Vec<String> = st.variations.iter().filter(|v| v.axis.len() == 4 && v.axis.is_ascii()).map(|v| format!("\"{}\" {}", v.axis, v.value)).collect();
|
||||
vec![
|
||||
StyleProperty::FontFamily(FontFamily::Source(Cow::Owned(fam.join(", ")))),
|
||||
@@ -1154,3 +1194,21 @@ fn first_ascent(line: &parley::Line<'_, RunBrush>) -> Option<f32> {
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{SmallCapsMode, feature_list, small_caps_mode};
|
||||
use photocraft_doc::text::{Caps, CharStyle};
|
||||
|
||||
#[test]
|
||||
fn uses_real_small_caps_only_when_the_selected_face_supports_smcp() {
|
||||
let style = CharStyle { caps: Caps::SmallCaps, ..Default::default() };
|
||||
let real = small_caps_mode(style.caps, true);
|
||||
let synthetic = small_caps_mode(style.caps, false);
|
||||
|
||||
assert_eq!(real, SmallCapsMode::OpenType);
|
||||
assert!(feature_list(&style, real).iter().any(|feature| feature == "\"smcp\" 1"));
|
||||
assert_eq!(synthetic, SmallCapsMode::Synthetic);
|
||||
assert!(!feature_list(&style, synthetic).iter().any(|feature| feature == "\"smcp\" 1"));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use photocraft_color::{Color, PixelFormat, SampleType};
|
||||
use photocraft_doc::TextLayer;
|
||||
use photocraft_doc::text::{CharStyle, FontFeature, Orientation, ParagraphRun, ParagraphStyle, TextAlign, TextDirection, TextRun, TextShape};
|
||||
use photocraft_doc::text::{Caps, CharStyle, FontFeature, Orientation, ParagraphRun, ParagraphStyle, TextAlign, TextDirection, TextRun, TextShape};
|
||||
use photocraft_geom::Affine;
|
||||
|
||||
use crate::{TextEngine, fonts};
|
||||
@@ -55,6 +55,20 @@ fn metrics_are_stable_and_scale_with_dpi() {
|
||||
assert!(a.lines[0].ascent > 8.0 && a.lines[0].ascent < 13.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_caps_are_synthesized_when_the_font_has_no_small_caps_feature() {
|
||||
let style = CharStyle { font_family: "Inter".into(), size_pt: 40.0, caps: Caps::SmallCaps, ..Default::default() };
|
||||
let mut engine = TextEngine::new();
|
||||
let small = engine.layout(&styled("aA", style), 72.0);
|
||||
let upper = engine.layout(&styled("AA", CharStyle { font_family: "Inter".into(), size_pt: 40.0, ..Default::default() }), 72.0);
|
||||
assert_eq!(small.glyphs.len(), 2);
|
||||
assert_eq!(small.glyphs[0].id, upper.glyphs[0].id, "lowercase uses the uppercase glyph");
|
||||
assert_eq!(small.glyphs[1].id, upper.glyphs[1].id, "uppercase remains uppercase");
|
||||
let size = |layout: &crate::TextLayout, glyph: usize| layout.faces[layout.glyphs[glyph].face as usize].size_px;
|
||||
assert!((size(&small, 0) - 28.0).abs() < 0.01, "{}", size(&small, 0));
|
||||
assert!((size(&small, 1) - 40.0).abs() < 0.01, "{}", size(&small, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_and_explicit_leading() {
|
||||
let mut e = TextEngine::new();
|
||||
|
||||
@@ -806,6 +806,13 @@ pub(crate) type NavigatorCache = (std::sync::Weak<Document>, u64, egui::TextureH
|
||||
/// How long adjustment-dialog settings must stay unchanged before the navigator shows them.
|
||||
const NAVIGATOR_SETTLE_MS: f64 = 200.0;
|
||||
|
||||
/// While a Move or Patch drag or a live paint stroke is under way, the navigator keeps its image
|
||||
/// of document `idx` and catches up on release: each step of the drag is a new preview, and a
|
||||
/// thumbnail composite of the whole document per step makes painting lag on large images.
|
||||
fn navigator_waits_for_drag(app: &PhotocraftApp, idx: usize) -> bool {
|
||||
crate::move_ui::showing(app) || crate::patch_preview::showing(app) || live_stroke(app, idx).is_some()
|
||||
}
|
||||
|
||||
/// The Navigator panel's image of document `idx`. With the CPU canvas that is the canvas texture
|
||||
/// itself; with the GPU canvas a thumbnail cached per revision, so an edit to a huge document
|
||||
/// doesn't also pay a full-resolution CPU composite for the navigator.
|
||||
@@ -825,9 +832,8 @@ pub fn navigator_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usiz
|
||||
{
|
||||
return Some(t.id());
|
||||
}
|
||||
// While a Move or Patch drag is under way the navigator keeps its image and catches up on release.
|
||||
if let Some((_, _, t)) = &cached
|
||||
&& (crate::move_ui::showing(app) || crate::patch_preview::showing(app))
|
||||
&& navigator_waits_for_drag(app, idx)
|
||||
{
|
||||
return Some(t.id());
|
||||
}
|
||||
@@ -4288,6 +4294,27 @@ mod tests {
|
||||
assert!(p.get("smoothing").is_none(), "the commit uses the session brush's smoothing, not a hard-coded one");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigator_keeps_its_image_during_a_live_brush_stroke() {
|
||||
// #1631: every step of a live stroke is a new preview key, so a navigator keyed by it
|
||||
// re-composited the whole document per frame while painting. It waits for the release.
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 80, "background": "transparent"})).unwrap();
|
||||
app.ui.tool = Tool::Brush;
|
||||
let m = egui::Modifiers::NONE;
|
||||
assert!(!navigator_waits_for_drag(&app, 0));
|
||||
tool_event(&mut app, ToolEvent::Down { x: 10.0, y: 40.0, pressure: 1.0 }, m);
|
||||
let mut keys = Vec::new();
|
||||
for x in [40.0, 70.0, 100.0] {
|
||||
tool_event(&mut app, ToolEvent::Move { x, y: 40.0, pressure: 1.0 }, m);
|
||||
keys.push(display_doc(&mut app, 0).1);
|
||||
assert!(navigator_waits_for_drag(&app, 0), "painting at x = {x}");
|
||||
}
|
||||
assert!(keys.windows(2).all(|k| k[0] != k[1]), "each step is a new preview: {keys:?}");
|
||||
tool_event(&mut app, ToolEvent::Up { x: 100.0, y: 40.0 }, m);
|
||||
assert!(!navigator_waits_for_drag(&app, 0), "the navigator catches up on release");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smoothed_stroke_shows_its_catch_up_tail_while_drawing() {
|
||||
// #73: with smoothing the brush lags behind the pointer and catches up at the end; the
|
||||
|
||||
@@ -124,6 +124,8 @@ pub fn status_bar_pro(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (Some(st), Some(i)) = (app.session.active(), app.session.active_index()) else {
|
||||
ui.label(RichText::new(tl!("No document")).color(t.text_dim));
|
||||
// A command run with no document open still reports here (#1567: File › Automate › Batch).
|
||||
status_message(app, ui, &t);
|
||||
return;
|
||||
};
|
||||
let text = status_info_text(&st.doc, &app.ui.chrome.status_info, tl!(app.ui.tool.label()), &profile_name(&st.doc));
|
||||
@@ -150,12 +152,18 @@ pub fn status_bar_pro(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
});
|
||||
if !app.ui.status.is_empty() {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(17.0, 16.0), Sense::hover());
|
||||
ui.painter().line_segment([r.center_top(), r.center_bottom()], Stroke::new(1.0, t.separator));
|
||||
let is_err = app.ui.status_error || app.ui.status.starts_with("Couldn");
|
||||
ui.label(RichText::new(&app.ui.status).color(if is_err { t.warning } else { t.text_faint }));
|
||||
status_message(app, ui, &t);
|
||||
}
|
||||
|
||||
/// The latest status message after a separator, in the warning colour when it is an error.
|
||||
fn status_message(app: &PhotocraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
if app.ui.status.is_empty() {
|
||||
return;
|
||||
}
|
||||
let (r, _) = ui.allocate_exact_size(vec2(17.0, 16.0), Sense::hover());
|
||||
ui.painter().line_segment([r.center_top(), r.center_bottom()], Stroke::new(1.0, t.separator));
|
||||
let is_err = app.ui.status_error || app.ui.status.starts_with("Couldn");
|
||||
ui.label(RichText::new(&app.ui.status).color(if is_err { t.warning } else { t.text_faint }));
|
||||
}
|
||||
|
||||
/// Home button at the very start of Photoshop 2026's options bar: toggles the Home (start)
|
||||
@@ -317,6 +325,21 @@ mod tests {
|
||||
assert_eq!(marquee_end("fixedRatio", w, h, false, [0.0, 0.0], [50.0, 20.0]), [50.0, 50.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_message_shows_with_no_document_open() {
|
||||
// #1567: File › Automate › Batch with no recorded action and no document answered nothing.
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.session.actions.list.clear();
|
||||
let e = crate::menus::invoke(&mut app, &egui::Context::default(), "file.automate.batch", json!({})).unwrap_err();
|
||||
app.ui.status = e.clone();
|
||||
app.ui.status_error = true;
|
||||
let mut h = Harness::builder().with_size(vec2(900.0, 40.0)).build_ui_state(|ui, app| status_bar_pro(app, ui), app);
|
||||
h.run_steps(2);
|
||||
assert!(h.query_by_label("No document").is_some());
|
||||
assert!(h.query_by_label(&e).is_some(), "status bar shows {e:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_status_key_has_text() {
|
||||
let d = doc();
|
||||
|
||||
@@ -773,6 +773,7 @@ pub fn inspect(app: &PhotocraftApp, ctx: &egui::Context) -> Value {
|
||||
})
|
||||
}),
|
||||
"panels": app.ui.panels,
|
||||
"view": app.ui.view,
|
||||
"views": app.ui.views,
|
||||
"dialogs": dialogs,
|
||||
"windows": app.ui.windows,
|
||||
@@ -925,6 +926,19 @@ mod tests {
|
||||
assert!(app.session.active().unwrap().doc.selection.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_inspect_reports_the_view_preferences() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
let before = call(&mut app, &ctx, "ui.inspect", json!({}));
|
||||
assert_eq!(before.pointer("/result/view/show/selection_edges"), Some(&json!(true)));
|
||||
assert_eq!(before.pointer("/result/view/screen_mode"), Some(&json!("standard")));
|
||||
assert_eq!(call(&mut app, &ctx, "ui.menu.invoke", json!({"id": "view.show.selectionEdges"}))["ok"], true);
|
||||
let after = call(&mut app, &ctx, "ui.inspect", json!({}));
|
||||
assert_eq!(after.pointer("/result/view/show/selection_edges"), Some(&json!(false)));
|
||||
assert_eq!(after["result"]["view"], json!(app.ui.view));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_execute_runs_with_defaults_but_menu_invoke_opens_the_dialog() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
|
||||
@@ -45,7 +45,7 @@ pub fn display_name(path: &str) -> String {
|
||||
|
||||
impl PhotocraftApp {
|
||||
/// Open `bytes` read from the file at `path`: the document is named after the file, keeps
|
||||
/// `path` for File › Save, and `path` goes to the top of Open Recent. Returns the import
|
||||
/// `path` for File › Save unless it is a derivative preview, and `path` goes to the top of Open Recent. Returns the import
|
||||
/// warnings (also shown to the user).
|
||||
pub fn open_file(&mut self, path: &str, bytes: &[u8]) -> Result<Vec<String>, String> {
|
||||
// Brushes/gradients go to the preset libraries (no document, no Open Recent entry).
|
||||
@@ -63,10 +63,11 @@ impl PhotocraftApp {
|
||||
}
|
||||
|
||||
/// Record that the active document was just opened from `path`: File › Save writes back to it
|
||||
/// (unless it's a template) and it goes to the top of Open Recent.
|
||||
/// (unless it's a template or derivative preview) and it goes to the top of Open Recent.
|
||||
pub(crate) fn opened_from(&mut self, path: &str) {
|
||||
if !is_template(path)
|
||||
&& let Some(st) = self.session.active_mut()
|
||||
&& !st.source_read_only
|
||||
{
|
||||
st.path = Some(path.to_string());
|
||||
}
|
||||
|
||||
@@ -80,6 +80,29 @@ fn open_file_sets_name_path_and_recent() {
|
||||
assert!(app.ui.notices.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn affinity_preview_does_not_acquire_the_source_path_even_when_renamed() {
|
||||
for automation in [false, true] {
|
||||
for path in ["/pics/source.af", "/pics/renamed.psd"] {
|
||||
let (mut app, written) = app_with(vec![None]);
|
||||
// The fake importer supplies the preview; its source signature controls path policy.
|
||||
if automation {
|
||||
app.open_automation_bytes(&display_name(path), b"\x00\xffKA").unwrap();
|
||||
app.opened_from(path);
|
||||
} else {
|
||||
app.open_file(path, b"\x00\xffKA").unwrap();
|
||||
}
|
||||
assert!(app.session.active().unwrap().source_read_only);
|
||||
assert!(app.session.active().unwrap().path.is_none());
|
||||
assert_eq!(app.ui.recent_files.first().map(String::as_str), Some(path));
|
||||
// Save asks for a new file (cancelled here) instead of writing over the source.
|
||||
assert_eq!(menus::invoke(&mut app, &egui::Context::default(), "file.save", json!({})).unwrap(), json!({"fileDialog": "save"}));
|
||||
answer(&mut app);
|
||||
assert!(written.borrow().is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_open_dialog_sets_path_so_save_writes_in_place() {
|
||||
// The dialog returns a full path: the document is named after the file, not the path.
|
||||
|
||||
@@ -823,6 +823,9 @@ impl PhotocraftApp {
|
||||
// Edit › Color Settings policies apply on open; mismatches can ask what to do.
|
||||
// No path yet: a bare name isn't a location to save back to (`open_file` sets the path).
|
||||
let (_, color) = self.session.open_document(doc, None);
|
||||
if let Some(st) = self.session.active_mut() {
|
||||
st.source_read_only = photocraft_io::affinity::is_affinity(bytes);
|
||||
}
|
||||
self.sync_views();
|
||||
self.ui.status = format!("Opened {name}");
|
||||
self.ui.status_error = false;
|
||||
@@ -868,6 +871,9 @@ impl PhotocraftApp {
|
||||
let (doc, warnings) = import(name, bytes)?;
|
||||
// The caller records the path it read from.
|
||||
self.session.add_document(doc, None);
|
||||
if let Some(st) = self.session.active_mut() {
|
||||
st.source_read_only = photocraft_io::affinity::is_affinity(bytes);
|
||||
}
|
||||
self.sync_views();
|
||||
self.ui.status = format!("Opened {name}");
|
||||
self.ui.status_error = false;
|
||||
|
||||
@@ -898,6 +898,7 @@ pub fn menu_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> f32 {
|
||||
let id = alt_click(id, ctx.input(|i| i.modifiers.alt));
|
||||
if let Err(e) = invoke(app, &ctx, &id, json!({})) {
|
||||
app.ui.status = e;
|
||||
app.ui.status_error = true;
|
||||
}
|
||||
}
|
||||
right
|
||||
|
||||
@@ -1130,7 +1130,11 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String
|
||||
ui.add(egui::TextEdit::singleline(&mut filter).desired_width(260.0).hint_text(tl!("command or shortcut")));
|
||||
});
|
||||
f.insert("filter".into(), json!(filter));
|
||||
let mut overrides: BTreeMap<String, String> = f.get("overrides").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default();
|
||||
// A malformed map (set over automation) is left as it is, so OK rejects it instead of
|
||||
// saving the empty map drawn in its place.
|
||||
let parsed = f.get("overrides").map(|v| serde_json::from_value::<BTreeMap<String, String>>(v.clone()));
|
||||
let overrides_ok = parsed.as_ref().is_none_or(Result::is_ok);
|
||||
let mut overrides = parsed.and_then(Result::ok).unwrap_or_default();
|
||||
let mut hidden: Vec<String> = f.get("hidden").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default();
|
||||
let mut colors: BTreeMap<String, String> = f.get("colors").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default();
|
||||
let mut selected = f.get("selected").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
@@ -1275,7 +1279,9 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String
|
||||
if !message.is_empty() {
|
||||
ui.label(RichText::new(&message).color(if message.contains("already in use") { t.warning } else { t.text_dim }));
|
||||
}
|
||||
f.insert("overrides".into(), json!(overrides));
|
||||
if overrides_ok {
|
||||
f.insert("overrides".into(), json!(overrides));
|
||||
}
|
||||
f.insert("hidden".into(), json!(hidden));
|
||||
f.insert("colors".into(), json!(colors));
|
||||
f.insert("selected".into(), json!(selected));
|
||||
@@ -1409,7 +1415,9 @@ pub fn confirm(app: &mut PhotocraftApp, f: &Map<String, Value>) -> Result<Value,
|
||||
let overrides = f.get("overrides").cloned().unwrap_or(json!({}));
|
||||
// Shortcuts moved to another command are taken from their old owner (shell
|
||||
// commands included, which the engine doesn't know).
|
||||
let mut ov: BTreeMap<String, String> = serde_json::from_value(overrides).unwrap_or_default();
|
||||
// The map replaces every stored override, so a malformed one is an error, not "none".
|
||||
let mut ov: BTreeMap<String, String> =
|
||||
serde_json::from_value(overrides).map_err(|e| format!("overrides must map command IDs to shortcut strings: {e}"))?;
|
||||
let items = shortcut_items(app);
|
||||
// Newly assigned shortcuts are checked first and take theirs from any holder, custom
|
||||
// overrides included; unchanged overrides only take theirs from defaults.
|
||||
@@ -2077,6 +2085,35 @@ mod tests {
|
||||
assert_eq!(effective_shortcut(&app, "file.open", Some("Cmd+O")), None, "File › Open stays cleared");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortcut_dialog_rejects_malformed_overrides() {
|
||||
// #956: a non-map `overrides` was read as "no overrides" and wiped every custom shortcut.
|
||||
let (mut app, _) = app_with_store();
|
||||
let ctx = egui::Context::default();
|
||||
app.run("edit.keyboardShortcuts", json!({"set": {"file.new": "Ctrl+Shift+N"}})).unwrap();
|
||||
let before = app.session.prefs().shortcuts.clone();
|
||||
assert!(!before.is_empty());
|
||||
for bad in [json!("bogus"), json!(["file.new"]), json!({"file.new": 5})] {
|
||||
let id = crate::menus::invoke(&mut app, &ctx, "edit.keyboardShortcuts", json!({})).unwrap()["dialog"].as_u64().unwrap();
|
||||
app.ui.dialog_mut(id).unwrap().fields.insert("overrides".into(), bad.clone());
|
||||
assert!(crate::dialogs::confirm(&mut app, id).is_err(), "{bad} accepted");
|
||||
assert_eq!(app.session.prefs().shortcuts, before, "{bad} changed the shortcuts");
|
||||
}
|
||||
|
||||
// Drawing the open dialog keeps the malformed value for OK to reject, instead of
|
||||
// replacing it with the empty map it shows.
|
||||
let id = crate::menus::invoke(&mut app, &ctx, "edit.keyboardShortcuts", json!({})).unwrap()["dialog"].as_u64().unwrap();
|
||||
app.ui.dialog_mut(id).unwrap().fields.insert("overrides".into(), json!("bogus"));
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(1280.0, 800.0)).build_eframe(move |cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().ui.dialogs.iter().find(|d| d.id == id).unwrap().fields["overrides"], json!("bogus"));
|
||||
assert!(crate::dialogs::confirm(h.state_mut(), id).is_err());
|
||||
assert_eq!(h.state().session.prefs().shortcuts, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_menu_items_disappear() {
|
||||
let (mut app, _) = app_with_store();
|
||||
|
||||
@@ -1045,6 +1045,29 @@ mod tests {
|
||||
assert_eq!(Tool::from_name("nope"), None);
|
||||
}
|
||||
|
||||
/// The README's "Everything in the box" tool heading and list name exactly the tools a user can
|
||||
/// pick, `Tool::ALL` (#996).
|
||||
#[test]
|
||||
fn readme_tool_list_matches_tool_all() {
|
||||
let readme = include_str!("../../../README.md");
|
||||
let (_, rest) = readme.split_once("<h4>🧰 ").unwrap();
|
||||
let (count, rest) = rest.split_once(" tools</h4>").unwrap();
|
||||
assert_eq!(count.parse::<usize>().unwrap(), Tool::ALL.len(), "README.md tool heading");
|
||||
let list = rest.trim_start().lines().next().unwrap();
|
||||
// "Rectangular and Elliptical Marquee" names two tools: "Rectangular Marquee", "Elliptical Marquee".
|
||||
let mut named: Vec<String> = Vec::new();
|
||||
for entry in list.split(" · ") {
|
||||
match entry.split_once(" and ").and_then(|(first, rest)| Some((first, rest.split_once(' ')?))) {
|
||||
Some((first, (second, noun))) => named.extend([format!("{first} {noun}"), format!("{second} {noun}")]),
|
||||
None => named.push(entry.to_owned()),
|
||||
}
|
||||
}
|
||||
named.sort();
|
||||
let mut labels: Vec<String> = Tool::ALL.iter().map(|tool| tool.label().trim_end_matches(" Tool").to_owned()).collect();
|
||||
labels.sort();
|
||||
assert_eq!(named, labels, "README.md tool list");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dialogs_open_and_close() {
|
||||
let mut s = UiState::default();
|
||||
|
||||
@@ -99,6 +99,7 @@ photocraft/
|
||||
│ ├─ adobe-assets/ photocraft-adobe-assets .abr .asl .aco/.ase .grd .pat .csh .atn .cube/.3dl, ACR .xmp presets (standalone, like psd)
|
||||
│ ├─ codecs/ photocraft-codecs png/jpeg/tiff/webp/gif/bmp/avif decode/encode, heif/heic decode (feature `heif`)
|
||||
│ ├─ heif/ photocraft-heif optional HEIF/HEIC decoder (heic-rs, pure Rust); used only by codecs behind its `heif` feature (standalone)
|
||||
│ ├─ affinity/ photocraft-affinity bounded native Affinity reader: archive, object stream, layers, vectors, text, pixels (standalone; read only)
|
||||
│ ├─ raw/ photocraft-raw clean-room camera RAW decode (DNG, CR2, TIFF/EP) + develop pipeline (standalone, like psd)
|
||||
│ ├─ format/ photocraft-format native document format (.pcraft bundle): manifest + content-addressed tiles
|
||||
│ ├─ io/ photocraft-io import/export orchestration; doc ⇄ PSD mapping; PDF/SVG import (features)
|
||||
|
||||
@@ -228,7 +228,7 @@ How each MCP tool maps onto control methods in bridge mode:
|
||||
| `doc_new {…}` | `engine.execute {command: "file.new", params}` |
|
||||
| `doc_inspect` | `engine.execute {command: "document.inspect"}` |
|
||||
| `doc_open {path}` | `app.open {path}` |
|
||||
| `doc_save {path}` / `doc_export {path}` | `app.save {path}` |
|
||||
| `doc_save {path?}` / `doc_export {path}` | `app.save {path?}` |
|
||||
| `doc_render_preview {max_side?}` | `ui.screenshot`, returned directly as PNG image content |
|
||||
| `session_list`, `ui_inspect` | `ui.inspect` |
|
||||
| `ui_screenshot {max_side?}` | `ui.screenshot`, returned as PNG image content |
|
||||
|
||||
+2
-1
@@ -355,6 +355,7 @@ against committed **sha256 manifests**. All pins are in one place:
|
||||
| `corpus/psd-tools/` | the complete psd-tools test set (309 files) | psd-tools upstream (MIT) | `xtask/psd-tools-corpus.sha256` |
|
||||
| `corpus/heif/` | 9 small HEIC/HEIF files (checkerboards, RGB strips, a grid-tiled photo with EXIF/XMP, each with Apple's decode as `.ref.png`; a 10-bit RGBA file with its source PNG), for the `heif` feature | heic-rs (MIT OR Apache-2.0) and pillow-heif (BSD-3-Clause) upstreams | `xtask/heif-corpus.sha256` |
|
||||
| `corpus/exr/` | the 5 deep OpenEXR test images (scanline deep data with half colour and u32 ID channels; 2.3 MB), checked against the ID manifests of their upstream sidecars | OpenEXR upstream at v3.5.2 (BSD-3-Clause) | `xtask/exr-corpus.sha256` |
|
||||
| `corpus/affinity/` | 21 public Affinity documents (four Affinity 3 `.af` files, `.afdesign` vector art, layer/shape/raster test files, a template with artboards), each holding Affinity's own render as its thumbnail | vector-art (CC0), AFDesignLoad, Jac21/Branding and AssetStoreTemplate (MIT) upstreams | `xtask/affinity-corpus.sha256` |
|
||||
| `corpus/pngsuite/` | PngSuite | schaik.com release archive (public domain) | (fixed archive) |
|
||||
|
||||
```sh
|
||||
@@ -362,7 +363,7 @@ cargo xtask corpus # where each corpus lives, its pin, present o
|
||||
cargo xtask corpus --all # fetch everything missing or stale (cold: about 15 s; verified copies are left alone)
|
||||
cargo xtask test-corpus # fetch, then cargo test --release --features corpus (+ heif on codecs, io) on psd, codecs, io, engine
|
||||
cargo xtask test-corpus -p io # narrow to one crate (repeat -p for more)
|
||||
cargo xtask test-corpus --changed # only if psd, io, codecs, compose, gpu, text or format changed vs origin/main
|
||||
cargo xtask test-corpus --changed # only if psd, io, codecs, compose, gpu, text, format or affinity changed vs origin/main
|
||||
cargo xtask test-corpus -- --nocapture # pass arguments to the test binaries (per-file tables)
|
||||
scripts/fetch-corpus.sh # the same as cargo xtask corpus --all
|
||||
```
|
||||
|
||||
+11
-1
@@ -28,6 +28,17 @@ readiness; treat it as an upper bound.
|
||||
|
||||
**Menu parity: 532 / 625 (85.1%)** on 2026-10-01, up from 224 (35.8%) the day before. See [`parity.md`](parity.md).
|
||||
|
||||
2026-10-08: Affinity `.af`, `.afdesign`, `.afphoto` and `.afpub` documents (container versions
|
||||
8–12) open **natively** ([format notes](../crates/affinity/README.md)): pages and artboards, layers
|
||||
and groups, curves and geometric shapes with fills, gradients and strokes, text as type layers,
|
||||
placed images as smart objects, pixel layers and masks, as an 8-bit RGB document without a source
|
||||
save path. Measured against the thumbnail Affinity embeds in each file, 21 pinned public documents
|
||||
(`cargo xtask corpus --affinity`) differ by 0–4.6 of 255 on average. Layer effects, adjustments,
|
||||
live filters, brush strokes, special shapes, master pages and CMYK/Lab/16-bit document colour are
|
||||
approximated or left out, each with a warning; damaged or unknown files fall back to the embedded
|
||||
preview. Affinity writing is not implemented: no Affinity installation was available to check
|
||||
written files, so `.af` export stays unsupported.
|
||||
|
||||
## Honest parity assessment (2026-10-05)
|
||||
|
||||
This is the reference answer to "how close are we to Photoshop parity, really". Agents: read it
|
||||
@@ -172,4 +183,3 @@ Each milestone has a **definition of done (DoD)** and must leave `main` green on
|
||||
| **M10** | Smart features | `ml` (ort native / ort-web on the web), Select Subject/Object/Sky, Remove BG, Remove tool, content-aware fill, healing, AI denoise, RAW develop | Quality benchmarks on a public dataset; timing budgets |
|
||||
| **M11** | Automation + formats | MCP server, batch, scripting, remaining formats (JP2, DICOM, DPX…), C2PA | An agent completes 10 scripted edit tasks via MCP |
|
||||
| **M12** | Pro parity | CMYK/Lab UI, print, HDR display, photomerge/HDR merge, timeline, layer comps, artboards, symmetry, neural filters | `xtask parity` ≥ 90% of Photoshop menu checklist |
|
||||
|
||||
+8
-8
@@ -6,15 +6,15 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
|
||||
|
||||
| Area | Done | Partial | Missing | Key numbers |
|
||||
|---|---:|---:|---:|---|
|
||||
| [Performance](#performance) | 3 | 22 | 13 | 47 scenarios: 3 within budget, 22 over budget or failing, 15 tracked only, 6 not run, 7 not measurable yet (baseline 2026-10-05) |
|
||||
| [Performance](#performance) | 3 | 22 | 13 | 47 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 6 not run, 7 not measurable yet (baseline 2026-10-05) |
|
||||
| [File compatibility](#file-compatibility) | 4 | 12 | 11 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 307/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 |
|
||||
| [Tools](#tools) | 11 | 14 | 8 | |
|
||||
| [Workspace and UI](#workspace-and-ui) | 0 | 7 | 16 | settings that do nothing: 55 of 141 (target 0) |
|
||||
| [Type](#type) | 0 | 3 | 6 | |
|
||||
| [Automation](#automation) | 3 | 2 | 6 | 627 of 627 menu items live (100.0%) |
|
||||
| [Reliability](#reliability) | 4 | 1 | 2 | never-crash lints on 27 of 27 crates; panic_hunt present |
|
||||
| [Reliability](#reliability) | 4 | 1 | 2 | never-crash lints on 28 of 28 crates; panic_hunt present |
|
||||
| [Distribution](#distribution) | 4 | 4 | 3 | |
|
||||
| [Open bugs](#open-bugs) | 12 | 4 | 16 | |
|
||||
| [Open bugs](#open-bugs) | 16 | 2 | 14 | |
|
||||
|
||||
## Performance
|
||||
|
||||
@@ -221,7 +221,7 @@ Menu wiring (`docs/parity.md`): 627 of 627 menu items live (100.0%). This counts
|
||||
|
||||
## Reliability
|
||||
|
||||
Never-crash attribute on 27 of 27 crate roots.
|
||||
Never-crash attribute on 28 of 28 crate roots.
|
||||
|
||||
| Id | Target | Status | Issue | Note |
|
||||
|---|---|---|---|---|
|
||||
@@ -259,12 +259,12 @@ Never-crash attribute on 27 of 27 crate roots.
|
||||
| BUG-203-4 | Unwritable directory errors clearly, never falls back to writing in place | done | [#203](https://github.com/storytold/photocraft/issues/203) | atomic.rs maps read-only dir, disk-full and cross-device rename errors to messages that say the original was not changed; there is no in-place fallback path |
|
||||
| BUG-203-5 | On any failure the original file is untouched and the temp file removed | done | [#203](https://github.com/storytold/photocraft/issues/203) | atomic.rs removes the temp file on every failure; tests assert the original bytes survive write and rename failures |
|
||||
| BUG-203-6 | Failure-injection tests: mid-write, rename and read-only dir errors keep original bytes | done | [#203](https://github.com/storytold/photocraft/issues/203) | format/src/atomic/tests.rs: Faulty AtomicFs injects mid-write StorageFull, rename failure and a read-only dir; locked-target retries, symlink and file-mode preservation covered too |
|
||||
| BUG-204-1 | Interface prefs (UI scale, font size, channels in colour, sliders, tooltips) wired or hidden | missing | [#204](https://github.com/storytold/photocraft/issues/204) | prefs.rs:196-202; no reader outside prefs.rs (see the prefs audit) |
|
||||
| BUG-204-2 | workspace.large_tabs, animated_zoom, auto_show_home_screen wired or hidden | missing | [#204](https://github.com/storytold/photocraft/issues/204) | prefs.rs:163,166,229; no reader outside prefs.rs |
|
||||
| BUG-204-3 | RAW prefs and ask_before_saving_layered_tiff read, or hidden | partial | [#204](https://github.com/storytold/photocraft/issues/204) | ask_before_saving_layered_tiff drives the TIFF Options prompt (ui-egui tiff_options_ui.rs); raw_defaults (prefs.rs:712) still has no readers |
|
||||
| BUG-204-1 | Interface prefs (UI scale, font size, channels in colour, sliders, tooltips) wired or hidden | done | [#204](https://github.com/storytold/photocraft/issues/204) | prefs_ui.rs tick reads ui_scale (sync_display_scale), ui_font_size (theme::set_ui_font_size) and show_tooltips (sync_tooltips); show_channels_in_color and dynamic_color_sliders are in prefs::HIDDEN_UNTIL_IMPLEMENTED |
|
||||
| BUG-204-2 | workspace.large_tabs, animated_zoom, auto_show_home_screen wired or hidden | done | [#204](https://github.com/storytold/photocraft/issues/204) | large_tabs and animated_zoom are in prefs::HIDDEN_UNTIL_IMPLEMENTED; auto_show_home_screen drives the Home screen (chrome_ui.rs home_button, canvas.rs) |
|
||||
| BUG-204-3 | RAW prefs and ask_before_saving_layered_tiff read, or hidden | done | [#204](https://github.com/storytold/photocraft/issues/204) | ask_before_saving_layered_tiff drives the TIFF Options prompt (ui-egui tiff_options_ui.rs); raw_defaults.open_in_camera_raw drives the Camera Raw dialog (camera_raw_ui.rs); the other raw_defaults fields are in prefs::HIDDEN_UNTIL_IMPLEMENTED |
|
||||
| BUG-204-4 | Recent files persisted through prefs across restarts | done | [#204](https://github.com/storytold/photocraft/issues/204) | crates/ui-egui/src/lib.rs::push_recent stores the list in preferences; prefs_ui::load restores it and recent_files_survive_a_restart_and_honour_the_count verifies reload |
|
||||
| BUG-204-5 | Recent-files list honours recent_file_count (default 20) | done | [#204](https://github.com/storytold/photocraft/issues/204) | lib.rs push_recent caps the list with recent_cap (recent_file_count); menus.rs covers default and custom limits |
|
||||
| BUG-204-6 | Test: every Prefs field is read somewhere or on a hidden allowlist | partial | [#204](https://github.com/storytold/photocraft/issues/204) | The scorecard's prefs audit counts unread fields; no engine test or allowlist yet |
|
||||
| BUG-204-6 | Test: every Prefs field is read somewhere or on a hidden allowlist | done | [#204](https://github.com/storytold/photocraft/issues/204) | crates/engine/tests/prefs_usage.rs every_preference_is_read_or_hidden: each field is read outside prefs.rs or listed in prefs::HIDDEN_UNTIL_IMPLEMENTED, never both |
|
||||
| BUG-205-1 | Free Transform with a selection moves only the selected part of a linked mask | missing | [#205](https://github.com/storytold/photocraft/issues/205) | transform_cmds.rs:127-130 warps the whole linked mask even when the selection split pixels |
|
||||
| BUG-205-2 | Live transform preview draws the layer with its mask applied | missing | [#205](https://github.com/storytold/photocraft/issues/205) | transform_tool.rs:173 preview_image uses the raw surface |
|
||||
| BUG-205-3 | edit.transform honours target mask: transforms only a targeted, unlinked mask | missing | [#205](https://github.com/storytold/photocraft/issues/205) | transform_cmds.rs:214-270 never reads a target param |
|
||||
|
||||
+9
-9
@@ -1,7 +1,7 @@
|
||||
# Scorecard checklist: open bugs (#203–#208).
|
||||
# Each item: id, short target, status (done | partial | missing), issue, note (evidence).
|
||||
# Statuses must be true on main. BUG-203 verified against 47f9306 on 2026-10-07; the rest
|
||||
# against 4b53f62 on 2026-10-05.
|
||||
# against 4b53f62 on 2026-10-05, except BUG-204 against ec350d6 on 2026-10-09.
|
||||
area = "bugs"
|
||||
title = "Open bugs"
|
||||
order = 9
|
||||
@@ -51,23 +51,23 @@ note = "format/src/atomic/tests.rs: Faulty AtomicFs injects mid-write StorageFul
|
||||
[[item]]
|
||||
id = "BUG-204-1"
|
||||
target = "Interface prefs (UI scale, font size, channels in colour, sliders, tooltips) wired or hidden"
|
||||
status = "missing"
|
||||
status = "done"
|
||||
issue = 204
|
||||
note = "prefs.rs:196-202; no reader outside prefs.rs (see the prefs audit)"
|
||||
note = "prefs_ui.rs tick reads ui_scale (sync_display_scale), ui_font_size (theme::set_ui_font_size) and show_tooltips (sync_tooltips); show_channels_in_color and dynamic_color_sliders are in prefs::HIDDEN_UNTIL_IMPLEMENTED"
|
||||
|
||||
[[item]]
|
||||
id = "BUG-204-2"
|
||||
target = "workspace.large_tabs, animated_zoom, auto_show_home_screen wired or hidden"
|
||||
status = "missing"
|
||||
status = "done"
|
||||
issue = 204
|
||||
note = "prefs.rs:163,166,229; no reader outside prefs.rs"
|
||||
note = "large_tabs and animated_zoom are in prefs::HIDDEN_UNTIL_IMPLEMENTED; auto_show_home_screen drives the Home screen (chrome_ui.rs home_button, canvas.rs)"
|
||||
|
||||
[[item]]
|
||||
id = "BUG-204-3"
|
||||
target = "RAW prefs and ask_before_saving_layered_tiff read, or hidden"
|
||||
status = "partial"
|
||||
status = "done"
|
||||
issue = 204
|
||||
note = "ask_before_saving_layered_tiff drives the TIFF Options prompt (ui-egui tiff_options_ui.rs); raw_defaults (prefs.rs:712) still has no readers"
|
||||
note = "ask_before_saving_layered_tiff drives the TIFF Options prompt (ui-egui tiff_options_ui.rs); raw_defaults.open_in_camera_raw drives the Camera Raw dialog (camera_raw_ui.rs); the other raw_defaults fields are in prefs::HIDDEN_UNTIL_IMPLEMENTED"
|
||||
|
||||
[[item]]
|
||||
id = "BUG-204-4"
|
||||
@@ -86,9 +86,9 @@ note = "lib.rs push_recent caps the list with recent_cap (recent_file_count); me
|
||||
[[item]]
|
||||
id = "BUG-204-6"
|
||||
target = "Test: every Prefs field is read somewhere or on a hidden allowlist"
|
||||
status = "partial"
|
||||
status = "done"
|
||||
issue = 204
|
||||
note = "The scorecard's prefs audit counts unread fields; no engine test or allowlist yet"
|
||||
note = "crates/engine/tests/prefs_usage.rs every_preference_is_read_or_hidden: each field is read outside prefs.rs or listed in prefs::HIDDEN_UNTIL_IMPLEMENTED, never both"
|
||||
|
||||
[[item]]
|
||||
id = "BUG-205-1"
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
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
|
||||
e1b121d7c3d02356baa5dea512f5aa1fe77249c28d7b227e29cab2bc67ec323a asset-store-template/AssetStore.aftemplate
|
||||
fbb196e918e302c16f8de9f2e6dffaef70ab3c317b32a9f44607fd4b2ead0206 jac21/SimpleLogo.afdesign
|
||||
9d874245936483de3ec04cca48b383028d33666801dff1814246a44e695e29b8 jac21/SimpleLogoBanner.afdesign
|
||||
87a7bd93de16c404f6c6dd9cdebff96ec47832a3b342d2ce0af73d9a87e209e0 vector-art/beer.afdesign
|
||||
09ba790c428e8e09dc0b9ddebb4f3db6daefaa2af1da492ee34e73fba1b8af69 vector-art/cactus.afdesign
|
||||
c3bcd0395b407493bc9d2877d424029b70f0e4c817a56e893f7ac74973df52e5 vector-art/car.afdesign
|
||||
112562bc91e7cccf003d34364430544208373c4fb4bb4ab9acdaf27cf0125047 vector-art/flowers.afdesign
|
||||
023531b8216f2d4da24ab715e3b313506712e50d5f5bf7da927f434def1d0ea2 vector-art/lion-track.afdesign
|
||||
c499cf3ca73cf7cd3e2e65bed048d1b8ecf03e7167b74d54f0ae6db31868cab8 vector-art/mexican-guy.af
|
||||
730e3fae51d9044ada58f51c7b5f9327d99e8d62376453bc251d0d26cda47743 vector-art/mexican-man.af
|
||||
5f5fccc0bd8be2bb3b2a07461ec77a012a61eef1883485d7c80f72d4383231ce vector-art/mexican-woman.af
|
||||
0a52bb923c949c566a4339b996bce1a67b683f61def1a73dfefb910effaa450d vector-art/playing-cards.af
|
||||
+10
-2
@@ -15,9 +15,10 @@ pub const CORPUS_CRATES: &[&str] = &["photocraft-psd", "photocraft-codecs", "pho
|
||||
const HEIF_CRATES: &[&str] = &["photocraft-codecs", "photocraft-io"];
|
||||
|
||||
/// Paths whose changes make `test-corpus --changed` run (the file-format and rendering crates).
|
||||
const CRITICAL: &[&str] = &["crates/psd/", "crates/io/", "crates/codecs/", "crates/heif/", "crates/compose/", "crates/gpu/", "crates/text/", "crates/format/"];
|
||||
const CRITICAL: &[&str] =
|
||||
&["crates/psd/", "crates/io/", "crates/codecs/", "crates/heif/", "crates/compose/", "crates/gpu/", "crates/text/", "crates/format/", "crates/affinity/"];
|
||||
|
||||
/// `cargo xtask corpus [--all | --pngsuite | --download | --psd | --psd-tools | --heif | --exr | --photoshop [--local]] [--update-manifest]`
|
||||
/// `cargo xtask corpus [--all | --pngsuite | --download | --psd | --psd-tools | --heif | --exr | --affinity | --photoshop [--local]] [--update-manifest]`
|
||||
pub fn cmd(args: &[&str]) -> Result<(), String> {
|
||||
let update = args.contains(&"--update-manifest");
|
||||
let mut did = false;
|
||||
@@ -44,6 +45,10 @@ pub fn cmd(args: &[&str]) -> Result<(), String> {
|
||||
corpus_pins::EXR.fetch(update)?;
|
||||
did = true;
|
||||
}
|
||||
if args.contains(&"--affinity") {
|
||||
corpus_pins::AFFINITY.fetch(update)?;
|
||||
did = true;
|
||||
}
|
||||
if args.contains(&"--photoshop") {
|
||||
if args.contains(&"--local") {
|
||||
corpus_pins::PHOTOSHOP.fetch_local(&local_clone()?, update)?;
|
||||
@@ -111,6 +116,8 @@ fn list() {
|
||||
pillow-heif@{} (BSD-3-Clause), manifest xtask/heif-corpus.sha256. Fetch: --heif
|
||||
corpus/exr/ [{}] the deep OpenEXR test images (BSD-3-Clause), openexr@{}
|
||||
manifest xtask/exr-corpus.sha256. Fetch: --exr
|
||||
corpus/affinity/ [{}] 21 public Affinity 1–3 documents (CC0, MIT): vector-art, AFDesignLoad,
|
||||
Jac21/Branding, AssetStoreTemplate; manifest xtask/affinity-corpus.sha256. Fetch: --affinity
|
||||
corpus/pngsuite/ [{}] PngSuite (public domain), {PNGSUITE_URL}. Fetch: --pngsuite
|
||||
corpus/tiff/, corpus/raw/ optional, copied in by hand
|
||||
|
||||
@@ -126,6 +133,7 @@ Pins: xtask/src/corpus_pins.rs. Moving one: change it, then --<name> --update-ma
|
||||
&PILLOW_HEIF_COMMIT[..12],
|
||||
status(corpus_pins::EXR.is_current()),
|
||||
&OPENEXR_COMMIT[..12],
|
||||
status(corpus_pins::AFFINITY.is_current()),
|
||||
status(png_ok),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,15 @@ pub const PILLOW_HEIF_COMMIT: &str = "b16be1196dfa465a342d68894696e685ca3655cb";
|
||||
/// https://github.com/AcademySoftwareFoundation/openexr (BSD-3-Clause): `src/test/bin/test_images`
|
||||
/// at the v3.5.2 tag (2026-10-03).
|
||||
pub const OPENEXR_COMMIT: &str = "69b2604fc76e370615438bdc8d2cd95b9349c12e";
|
||||
/// https://github.com/samuel-etver/vector-art (CC0-1.0): `simple/`, four Affinity 3 `.af` files and
|
||||
/// Designer `.afdesign` files (main, 2026-04-22).
|
||||
pub const VECTOR_ART_COMMIT: &str = "255f8add3c8f0740196e22bd59502b811b532f0b";
|
||||
/// https://github.com/NickBeeuwsaert/AFDesignLoad (MIT): `testDesigns/`, purpose-made Designer files.
|
||||
pub const AFDESIGNLOAD_COMMIT: &str = "a18dd50a7079fb861a28835eeefa0d015f03f4a1";
|
||||
/// https://github.com/Jac21/Branding (MIT): Designer logos with artboards and placed images.
|
||||
pub const JAC21_BRANDING_COMMIT: &str = "57ae3f45bf4637f6927c0b07de25b922c55f944f";
|
||||
/// https://github.com/eviltwo/AssetStoreTemplate (MIT): an Affinity template with artboards.
|
||||
pub const ASSET_STORE_TEMPLATE_COMMIT: &str = "f671981ee89d15526285b3cc87b4393d3defe644";
|
||||
/// PngSuite (public domain), a fixed release archive.
|
||||
pub const PNGSUITE_URL: &str = "http://www.schaik.com/pngsuite/PngSuite-2017jul19.tgz";
|
||||
|
||||
@@ -163,5 +172,58 @@ The `*.deep.exr` files of https://github.com/AcademySoftwareFoundation/openexr/t
|
||||
},
|
||||
};
|
||||
|
||||
/// `corpus/affinity`: public Affinity documents saved by Affinity 1.x to 3.x, chosen for having no
|
||||
/// personal paths in their metadata. Each embeds Affinity's own render of itself (its thumbnail),
|
||||
/// the oracle `photocraft-io`'s corpus test compares the imported document against. The manifest
|
||||
/// selects the files.
|
||||
pub const AFFINITY: PinnedCorpus = PinnedCorpus {
|
||||
name: "affinity",
|
||||
dest: "affinity",
|
||||
upstreams: &[
|
||||
Upstream {
|
||||
prefix: "vector-art/",
|
||||
repo: "samuel-etver/vector-art",
|
||||
commit: VECTOR_ART_COMMIT,
|
||||
subdir: "simple",
|
||||
extras: &[("LICENSE", "vector-art/LICENSE")],
|
||||
},
|
||||
Upstream {
|
||||
prefix: "afdesignload/",
|
||||
repo: "NickBeeuwsaert/AFDesignLoad",
|
||||
commit: AFDESIGNLOAD_COMMIT,
|
||||
subdir: "testDesigns",
|
||||
extras: &[("LICENSE", "afdesignload/LICENSE")],
|
||||
},
|
||||
Upstream {
|
||||
prefix: "jac21/",
|
||||
repo: "Jac21/Branding",
|
||||
commit: JAC21_BRANDING_COMMIT,
|
||||
subdir: "Logos/JC/DesignerFiles/Affinity",
|
||||
extras: &[("LICENSE.md", "jac21/LICENSE.md")],
|
||||
},
|
||||
Upstream {
|
||||
prefix: "asset-store-template/",
|
||||
repo: "eviltwo/AssetStoreTemplate",
|
||||
commit: ASSET_STORE_TEMPLATE_COMMIT,
|
||||
subdir: "AssetStoreTemplate",
|
||||
extras: &[("LICENSE", "asset-store-template/LICENSE")],
|
||||
},
|
||||
],
|
||||
manifest: "xtask/affinity-corpus.sha256",
|
||||
exts: &["af", "afdesign", "aftemplate"],
|
||||
subset: true,
|
||||
sources_md: |c| {
|
||||
format!(
|
||||
"# Affinity test corpus\n\nPublic Affinity documents, unmodified, with their licences next to them: \
|
||||
https://github.com/samuel-etver/vector-art/tree/{VECTOR_ART_COMMIT}/simple (`vector-art/`, CC0-1.0), \
|
||||
https://github.com/NickBeeuwsaert/AFDesignLoad/tree/{AFDESIGNLOAD_COMMIT}/testDesigns (`afdesignload/`, MIT, Copyright (c) 2015 Nick Beeuwsaert), \
|
||||
https://github.com/Jac21/Branding/tree/{JAC21_BRANDING_COMMIT}/Logos/JC/DesignerFiles/Affinity (`jac21/`, MIT, Copyright (c) 2018 Jeremy Cantu) and \
|
||||
https://github.com/eviltwo/AssetStoreTemplate/tree/{ASSET_STORE_TEMPLATE_COMMIT}/AssetStoreTemplate (`asset-store-template/`, MIT). \
|
||||
Selected by and sha256-verified against `{}` by `cargo xtask corpus --affinity`. Gitignored; never commit these files.\n",
|
||||
c.manifest
|
||||
)
|
||||
},
|
||||
};
|
||||
|
||||
/// Every pinned corpus, in fetch order.
|
||||
pub const ALL: &[&PinnedCorpus] = &[&PSD_MIXED, &PSD_TOOLS, &PHOTOSHOP, &HEIF, &EXR];
|
||||
pub const ALL: &[&PinnedCorpus] = &[&PSD_MIXED, &PSD_TOOLS, &PHOTOSHOP, &HEIF, &EXR, &AFFINITY];
|
||||
@@ -42,6 +42,7 @@ pub const TABLE: &[(&str, Class)] = &[
|
||||
// Optional HEIF/HEIC decoder (heic-rs), used only by `codecs` behind its `heif` feature.
|
||||
("heif", Class::Standalone),
|
||||
("raw", Class::Standalone),
|
||||
("affinity", Class::Standalone),
|
||||
("adobe-assets", Class::Standalone),
|
||||
// Pen tablet input (the one isolated `unsafe` helper: AppKit interop on macOS).
|
||||
("tablet", Class::Standalone),
|
||||
|
||||
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user