AF.5: Affinity import — strokes, text decoration, text placement, outline effects, CMYK photos

- Strokes set to None no longer import as black 1px strokes.
- Underline and strikethrough are kept.
- Frame text starts at cap height; lines hanging out of a frame make it taller;
  mixed-alignment artistic text lines up within its longest line; line breaks
  inside a paragraph get no paragraph spacing.
- Table cell text taller than its cell is centred and stays visible.
- Outline effects on stroked shapes are drawn around the stroke.
- CMYK pixel layers go through the document's embedded CMYK profile; without
  one, opaque layers stay CMYK.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
dwetscher
2026-10-22 18:24:57 +07:00
committed by bflatastic
co-authored by Claude Opus 5.5
parent 76399175eb
commit fd50bd6f84
22 changed files with 3744 additions and 384 deletions
+3 -2
View File
@@ -242,8 +242,9 @@ Pathfinder and Shape Builder, paint, gradients, appearance and transparency, typ
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, legacy version 7 `.ai` keeping layers, groups and type, EPS, DXF, EMF/WMF, raster formats, PSD out and PSD/PSB in as their merged image, Print, Package).
Affinity documents (`.af` from Affinity 3, `.afdesign`, `.afpub` and, by their content, `.afphoto` from Affinity 1 and 2) open
and place natively: layers, groups, artboards and pages, curves and shapes, fills, gradients and strokes, clipping
and masks, text and images, with what didn't come in (effects, adjustments, brushes, master pages…) listed in the
import warning; a file whose native data can't be read opens as its embedded preview, saying why. VectorCraft
and masks, text with each paragraph's indents, spacing and leading, tables (as text frames and lines), master
pages, shadows, glows, outlines and overlays, embedded Affinity documents and images, with what didn't come in
(adjustments, blurs, bevels, brushes…) listed in the import warning; a file whose native data can't be read opens as its embedded preview, saying why. VectorCraft
doesn't write Affinity files. Current `.af` validation includes 33 pinned files, native save/reload
and every-board SVG/PDF/PSD export, with fixes for Affinity 3 artboards, source-backed JPEGs and text runs.
[Scope and limits](crates/affinity/README.md); [source audit and remaining gaps](docs/affinity-validation.md).
+2 -1
View File
File diff suppressed because one or more lines are too long
+31 -12
View File
@@ -11,6 +11,7 @@ Affinity files.
| Affinity | VectorCraft |
|---|---|
| Pages, Publisher spreads (one or two pages) | artboards, spreads side by side |
| Master pages (`MpCh`) shown on pages through master page instances (`MPIN`: master, page offset, master page, count) | a locked "Master A, B…" layer at the bottom holding each master page's objects on the pages that show it |
| Artboards (legacy `ABEn` or Affinity 3 `phrp`/`aprp`), with name, paint and any transform | artboards plus a clip group with the actual vector outline, background and stroke |
| Layers (`Scop`), groups (`Grup`), pass-through or isolated | layers and sublayers, groups |
| Curves (`PCrv`): cubic subpaths, closed flags, live corners (`CnrD`) | paths (multi-subpath curves fill even-odd) |
@@ -21,22 +22,24 @@ Affinity files.
| Pixel masks (`MRst`, attached or as a layer in a group) | opacity mask |
| Solid fills: RGBA, HSLA, CMYK, grey, Lab (D50) | fill appearance (alpha as fill opacity) |
| Linear, elliptical and radial gradients with Affinity's midpoint bias | gradients (the bias as the midpoint where the blend is half way) |
| Strokes: weight, scale with object, dash pattern and phase, alignment, miter limit, behind the fill | stroke appearance |
| Strokes: weight, scale with object, dash pattern and phase, alignment, miter limit, behind the fill; a stroke whose style is None (every shape stores one, black and 1 px) is left out | stroke appearance |
| Fill layers (`FRst`) | filled rectangles |
| Artistic and frame text: Unicode scalar-indexed runs, stored fallback font, family/weight/style, boolean OpenType features, all caps, size, tracking, fixed leading, colour, paragraph alignment, first baseline | point type and area type |
| Artistic and frame text: Unicode scalar-indexed runs, stored fallback font, family/weight/style, boolean OpenType features, all caps, underline, strikethrough, size, tracking, horizontal scale, fixed leading, colour, outlined characters, first baseline; each paragraph's alignment, indents, space before and after, fixed and automatic leading; frame text sized by its text scale (`FTxS`), not its frame's transform | point type and area type, one paragraph style per paragraph; artistic text whose paragraphs align differently keeps them aligned within its longest line (by indents), as Affinity draws it; a frame's first line hangs from its capitals' tops, and a frame whose last lines' descenders hang out of it is made just tall enough to show them; line breaks inside a paragraph become paragraphs without spacing between them; table cells place their text as Affinity does (centred from the capitals' tops to the last baseline, reaching out of a cell too short for it) |
| Tables (`TxtT`): grid, cell text, merged cells, cell insets and vertical alignment, the line on every cell edge | a group of area type per cell and a stroked line per edge (VectorCraft has no tables) |
| Placed images (`ImgN`): the original JPEG/PNG embedded in the file | embedded images |
| Pixel layers (`Rstr`): RGBA 8/16-bit and CMYK 8-bit tiles, cropped to their content; source-backed RGBA8 JPEG/PNG tiles with matching dimensions and zero origin | embedded PNG images |
| Embedded documents and symbols (`EmbN`) | the picture Affinity cached of them |
| Pixel layers (`Rstr`): RGBA 8/16-bit and CMYK 8-bit tiles, cropped to their content; source-backed RGBA8 JPEG/PNG tiles with matching dimensions and zero origin; the document's CMYK colour profile (`CMYP`) | embedded PNG images; CMYK layers through the document's profile (relative colorimetric, black-point compensation, as Affinity converts them), or, without one, CMYK images that keep their inks |
| Embedded Affinity documents (`EmbN` with an `EmDc` entry), four deep at most | their first page's content, editable (their artboards only clip, they don't become artboards); other embedded documents and symbols as the picture Affinity cached of them |
| Layer effects: drop shadows, outer glows, inner shadows; outlines; colour and gradient overlays in any blend mode | Stylize › Drop Shadow and Outer Glow, Inner Glow at the edge (approximated); one more stroke on shapes (around their own stroke), the characters' stroke on text; overlays baked into fill, stroke, text colours and pixels |
| Opacity, visibility, lock, names, the blend modes VectorCraft has | the same |
Everything else is reported in the import warnings, one line per kind with a count, never
dropped silently: layer effects, adjustment layers and live filters, brush and pressure strokes,
transparency gradients, fill opacity, bitmap fills, master pages, conical gradients (as radial),
dropped silently: bevel, blur and other layer effects, adjustment layers and live filters, brush and pressure strokes,
transparency gradients, fill opacity, bitmap fills, master pages based on other master pages, conical gradients (as radial),
special shapes (cloud, heart, cog, callouts, arrows…, imported as their bounding ellipse), corner
types other than round or straight, stars with rounded points, several fills or strokes on one object (the
active one is used), outlined or scaled text, text fields such as page numbers, frames with
columns or a curved outline, paragraph indents/spacing, mixed paragraph alignments, unsupported font selectors, grey/Lab/32-bit pixels, CMYK pixels (converted to RGB without the
document's profile) and Affinity-only blend modes (Add, Linear Light… as Normal).
active one is used), skewed or flipped text, characters of different transparency in one text, text fields such as page numbers, text threading, frames with
columns or a curved outline, table cell fills, unsupported font selectors, grey/Lab/32-bit pixels, CMYK pixels with transparency in a document
without a CMYK profile (converted through VectorCraft's working CMYK profile) and Affinity-only blend modes (Add, Linear Light… as Normal).
When the native document can't be read at all, the engine opens the embedded PNG preview instead,
with a warning that names the reason; that preview can't be placed, used as a template or mined
@@ -78,10 +81,22 @@ of CC0 sample files.
* **Read-only.** Nothing is written in Affinity's format; export waits until someone can check
written files in Affinity itself.
The reader also has an independent copy in PhotoCraft's `photocraft-affinity`.
The reader also has independent copies in PhotoCraft's `photocraft-affinity` and DesignCraft's
`designcraft-affinity`. Tables, paragraph formats, text scale, master pages, layer effects,
overlays, embedded documents, large pixel layers and HEIC photos were added in DesignCraft
([storytold/designcraft#291](https://github.com/storytold/designcraft/pull/291)) and brought back
here, from the same public fixtures and the thumbnails of documents their owners made, used only
locally. Automatic leading (the font size plus the font's line gap, for artistic and frame text)
was fitted on the thumbnails of one such local document with 56 artboards and on the public Patchy
text-frame fixture.
## Validation
* `engine/tests/affinity_features.rs` covers pages, several artboards per canvas and per spread,
master pages, tables, paragraph formats, text scale, layer effects, overlays and embedded
documents (whose artboards don't become artboards) on synthetic documents, and mutates them.
`engine/tests/affinity_local.rs` compares every artboard of local documents with their thumbnail
when `AFFINITY_SAMPLES` points at a folder (never in CI; nothing is committed).
* 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.
@@ -132,8 +147,12 @@ anything is allocated: a value takes about 40 bytes in memory however few it too
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; compound outlines also cap recursion at 128 levels, operand visits at 100 000
and cumulative geometry work at four million, rejecting cycles and partial results on limits.
Pie sweeps normalize finite angles in constant time. Pixel layers are limited to 64 megapixels
(cropped to their content first); gradients to 1024 stops. Text collection is bounded to a
Pie sweeps normalize finite angles in constant time. Pixel layers are decoded at no more than 64
megapixels each (cropped to their content first; larger ones are read at a half, a quarter… of
their resolution) and 512 megapixels for the whole document, embedded documents included; pixel
masks are read only over what they mask. Embedded documents nest four deep at most and count
against the importing document's limits. Tables have at most 4096 rows and columns, and 64 layer
effects per node are read. Gradients are limited to 1024 stops. Text collection is bounded to a
million characters per node before allocation, with a cumulative 16 MiB budget for owned font
names, features and gradient stops. Exhaustion warns and uses default attributes for remaining
text, preserving its characters.
+15
View File
@@ -263,6 +263,21 @@ impl<'a> Archive<'a> {
}
/// Extract an entry by name: decompress, undo the byte predictor and verify the CRC.
/// What is left of the limits for documents embedded in this one.
pub(crate) fn remaining(&self) -> Limits {
Limits { max_entry: self.limits.max_entry, max_total: self.limits.max_total.saturating_sub(self.extracted) }
}
/// Count `n` bytes extracted elsewhere (from embedded documents) against this import.
pub(crate) fn charge(&mut self, n: usize) -> Result<(), Error> {
let total = self.extracted.checked_add(n).ok_or(Error::Limit("document too large"))?;
if total > self.limits.max_total {
return Err(Error::Limit("document too large"));
}
self.extracted = total;
Ok(())
}
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"))?;
+1
View File
@@ -14,6 +14,7 @@ mod access;
pub mod container;
mod geometry;
pub mod model;
pub mod overlay;
pub mod paint;
pub mod preview;
mod raster;
+530 -50
View File
@@ -1,4 +1,4 @@
//! The document model VectorCraft imports: spreads, artboards, layers, groups, curves, shapes,
//! The document model the Craft apps import: 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.
@@ -175,17 +175,54 @@ pub struct TextRun {
/// Boolean OpenType feature overrides, as tags or `-tag` for disabled features.
pub features: Vec<String>,
pub all_caps: bool,
/// Underlined characters (`Ints` slot 0 is 1; identified on underlined words in a document its
/// owner made, all other characters there and in the public corpus have 0).
pub underline: bool,
/// Struck-through characters (`Ints` slot 1 is 1; identified the same way, on prices struck
/// through in another document its owner made).
pub strikethrough: bool,
/// Font size in document pixels, before the node's transform.
pub size: f64,
/// Tracking in em.
pub tracking: f64,
/// Horizontal scale of the glyphs (1 = as designed).
pub h_scale: f64,
/// Fixed line pitch in document pixels, when the run overrides automatic leading.
pub leading: Option<f64>,
pub fill: Paint,
/// Outlined characters: the outline's paint and width (node space).
pub stroke: Option<crate::paint::Stroke>,
/// The run's paragraph: alignment, indents and spacing.
pub paragraph: Paragraph,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
/// Paragraph alignment, indents, spacing and leading, in node space (document pixels before the
/// node's text scale).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Paragraph {
pub align: Align,
/// Indent of every line but the first, from the left.
pub left: f64,
pub right: f64,
/// Indent of the first line, from the left.
pub first: f64,
pub space_before: f64,
pub space_after: f64,
/// Fixed distance between baselines, when the paragraph sets one.
pub leading: Option<f64>,
/// Automatic leading as a multiple of the font's own line height (ascent plus descent).
pub auto_leading: f64,
}
impl Default for Paragraph {
fn default() -> Self {
Self { align: Align::Left, left: 0.0, right: 0.0, first: 0.0, space_before: 0.0, space_after: 0.0, leading: None, auto_leading: 1.0 }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Align {
#[default]
Left,
Center,
Right,
@@ -202,12 +239,67 @@ pub struct Text {
pub frame: Option<Rect>,
/// Node space to document pixels.
pub transform: Affine,
/// Node space to document pixels for the type (its linear part matters): artistic text scales
/// with its node, frame text only with its frame's text scale.
pub text_transform: Affine,
}
/// Vertical alignment of a table cell's text.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VAlign {
Top,
Center,
Bottom,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TableCell {
pub runs: Vec<TextRun>,
/// Space between the cell edges and its text, in node space: left, top, right, bottom.
pub inset: [f64; 4],
pub valign: VAlign,
/// Part of the cell to its left (merged): it has no text of its own.
pub merged_left: bool,
}
/// A table: a grid of cells with text, and a line on every cell edge.
#[derive(Debug, Clone, PartialEq)]
pub struct Table {
/// Column boundaries (one more than the columns), increasing, in node space.
pub columns: Vec<f64>,
/// Row boundaries (one more than the rows), increasing, in node space.
pub rows: Vec<f64>,
/// Row by row.
pub cells: Vec<TableCell>,
/// Vertical edges row by row, one more per row than the columns; `None` draws no line.
pub vertical: Vec<Option<Stroke>>,
/// Horizontal edges boundary by boundary (one more than the rows), one per column.
pub horizontal: Vec<Option<Stroke>>,
/// Node space to document pixels.
pub transform: Affine,
/// Node space to document pixels for the cells' type: resizing a table moves its grid, not
/// its type.
pub text_transform: Affine,
}
impl Table {
pub fn ncols(&self) -> usize {
self.columns.len().saturating_sub(1)
}
pub fn nrows(&self) -> usize {
self.rows.len().saturating_sub(1)
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum Pixels {
/// Straight-alpha RGBA, 8 bits per channel, row by row.
Rgba8(Vec<u8>),
/// A CMYK pixel layer: cyan, magenta, yellow and black ink amounts (0 none, 255 full) and
/// straight alpha, 8 bits each, row by row. Left in CMYK for the importer to show through a
/// colour profile: the profile-free formula turns the extra cyan of a separation's neutrals
/// into a blue cast.
Cmyka8(Vec<u8>),
/// The original file a placed image was made from (PNG, JPEG…), as embedded.
Encoded(Vec<u8>),
}
@@ -246,11 +338,78 @@ pub enum Kind {
even_odd: bool,
},
Text(Text),
Table(Table),
Image(Image),
/// A master page shown on this spread's pages: pages `page..page + count` show the master
/// spread [`Document::masters`]`[master]`'s pages from `master_page` on.
MasterInstance {
master: usize,
page: usize,
master_page: usize,
count: usize,
},
/// Something this reader does not import; its children are still imported.
Unsupported,
}
/// A layer effect. Lengths are document pixels; `angle` is the direction the shadow is offset in
/// (radians, clockwise from +x as y points down: π/2 puts it below).
#[derive(Debug, Clone, PartialEq)]
pub enum Effect {
DropShadow {
color: crate::paint::Color,
opacity: f64,
radius: f64,
distance: f64,
angle: f64,
},
InnerShadow {
color: crate::paint::Color,
opacity: f64,
radius: f64,
distance: f64,
angle: f64,
},
OuterGlow {
color: crate::paint::Color,
opacity: f64,
radius: f64,
},
/// An outline of `width` around the node's shape: `align` 0 outside, 1 inside, 2 centred.
Outline {
color: crate::paint::Color,
opacity: f64,
width: f64,
align: u16,
},
/// Bevel and emboss: lit and shaded edges `size` wide. `angle` is the light's azimuth
/// (radians, counter-clockwise from +x with y up), `depth` Affinity's depth (5 by default);
/// `inner` is false for the outer, emboss and pillow kinds.
Bevel {
size: f64,
depth: f64,
angle: f64,
highlight: crate::paint::Color,
highlight_opacity: f64,
shadow: crate::paint::Color,
shadow_opacity: f64,
inner: bool,
},
/// Colour overlay: everything the node paints takes `color`, laid over it in `blend` mode at
/// `opacity` (the node's own alpha is kept).
ColorOverlay {
color: crate::paint::Color,
opacity: f64,
blend: Blend,
},
/// Gradient overlay: a colour overlay whose colour comes from `gradient` (document pixels).
GradientOverlay {
gradient: crate::paint::Gradient,
opacity: f64,
blend: Blend,
},
}
#[derive(Debug, Clone, PartialEq)]
pub struct Node {
pub class: Tag,
@@ -264,6 +423,8 @@ pub struct Node {
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>,
/// Layer effects the importer can show or bake in (others are reported).
pub effects: Vec<Effect>,
/// Bottom to top.
pub children: Vec<Node>,
}
@@ -285,12 +446,21 @@ pub struct Document {
/// Pixels per inch: all geometry is in document pixels at this resolution.
pub dpi: f64,
pub spreads: Vec<Spread>,
/// Master spreads, which [`Kind::MasterInstance`] nodes show on pages.
pub masters: 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>,
/// The document's CMYK colour profile (an ICC file), when it embeds one: the root's `CMYP`,
/// which each CMYK pixel layer's colour settings (`Prof`) also name, with relative
/// colorimetric intent and black-point compensation in every document checked.
pub cmyk_profile: Option<Vec<u8>>,
}
/// Largest embedded colour profile kept (real CMYK profiles are a few megabytes).
const MAX_PROFILE_BYTES: usize = 32 << 20;
/// Deepest layer nesting followed; deeper content is reported, not imported.
const MAX_TREE_DEPTH: usize = 128;
/// Total layer nodes imported.
@@ -300,6 +470,9 @@ const MAX_NODES: usize = 500_000;
/// count the bytes extracted from it, and a layer of one flat colour needs none (#1704).
const MAX_LAYER_PIXELS: u64 = 512 << 20;
/// Embedded documents inside embedded documents are followed this deep.
const MAX_EMBED_DEPTH: usize = 4;
/// Read a document: archive, `doc.dat` and the model.
pub fn read(bytes: &[u8], limits: Limits) -> Result<Document, Error> {
read_within(bytes, limits, MAX_LAYER_PIXELS)
@@ -307,35 +480,76 @@ pub fn read(bytes: &[u8], limits: Limits) -> Result<Document, Error> {
/// [`read`], its pixel layers holding at most `pixels` pixels together.
pub(crate) fn read_within(bytes: &[u8], limits: Limits, pixels: u64) -> Result<Document, Error> {
read_at(bytes, limits, pixels, Affine::IDENTITY, 0).map(|r| r.document)
}
/// A document read by [`read_at`], with what it used of its budgets.
struct ReadAt {
document: Document,
/// Bytes its archive extracted.
extracted: usize,
/// Pixels its pixel layers left of the allowance.
pixels_left: u64,
}
/// Read a document whose spreads are placed by `base` (for embedded documents, nested `depth`
/// deep), its pixel layers holding at most `pixels` pixels together.
fn read_at(bytes: &[u8], limits: Limits, pixels: u64, base: Affine, depth: usize) -> Result<ReadAt, Error> {
let mut archive = Archive::open(bytes, limits)?;
let doc = archive.read("doc.dat")?;
let s = stream::parse(&doc)?;
let mut reader = Reader::new(&s, &mut archive);
let mut reader = Reader::new(&s, &mut archive, base, depth);
reader.pixels_left = pixels;
reader.document()
let (document, pixels_left) = reader.document()?;
Ok(ReadAt { document, extracted: archive.extracted(), pixels_left })
}
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>,
pub(crate) warnings: BTreeMap<String, usize>,
nodes: usize,
active: HashSet<ObjId>,
/// Where the spreads go: the identity, or an embedding document's placement.
base: Affine,
/// The linear part of `base`, which also scales frame text.
pub(crate) type_base: Affine,
/// How deep inside embedded documents this one is.
depth: usize,
/// An embedded document's content, waiting to become its node's children.
pending: Option<Vec<Node>>,
/// The master spreads, in the order of [`Document::masters`].
master_ids: Vec<ObjId>,
/// Pixels the pixel layers still to come may decode ([`MAX_LAYER_PIXELS`] in all).
pub(crate) pixels_left: u64,
}
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(), pixels_left: MAX_LAYER_PIXELS }
pub(crate) fn new(s: &'s Stream, archive: &'b mut Archive<'a>, base: Affine, depth: usize) -> Self {
let [a, b, c, d, ..] = base.0;
Self {
s,
archive,
dpi: 72.0,
warnings: BTreeMap::new(),
nodes: 0,
active: HashSet::new(),
base,
type_base: Affine([a, b, c, d, 0.0, 0.0]),
depth,
pending: None,
master_ids: Vec::new(),
pixels_left: MAX_LAYER_PIXELS,
}
}
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> {
/// The document, and the pixels its pixel layers left of the allowance.
fn document(mut self) -> Result<(Document, u64), 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);
@@ -347,40 +561,65 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
}
});
let doc = s.obj(root, b"DocR").ok_or(Error::Malformed("document has no root node"))?;
self.master_ids = s.objs(doc, b"MpCh").into_iter().filter(|m| s.is(*m, b"Sprd")).collect();
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;
if let Some(sp) = self.spread(doc, spread)? {
spreads.push(sp);
}
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 mut masters = Vec::new();
for master in self.master_ids.clone() {
match self.spread(doc, master)? {
Some(sp) => masters.push(sp),
// Keep the indices of `MasterInstance::master` valid.
None => masters.push(Spread {
bounds: Rect { x0: 0.0, y0: 0.0, x1: 0.0, y1: 0.0 },
pages: Vec::new(),
transparent: true,
nodes: Vec::new(),
}),
}
}
let cmyk_profile = s
.obj(root, b"CMYP")
.and_then(|p| s.bytes(p, b"Data"))
.filter(|b| b.len() <= MAX_PROFILE_BYTES && b.get(16..20) == Some(b"CMYK".as_slice()))
.map(<[u8]>::to_vec);
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 })
Ok((Document { dpi: self.dpi, spreads, masters, saved_by, warnings, cmyk_profile }, self.pixels_left))
}
/// 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> {
/// A spread (`Sprd`) of the document `doc`, or of its master spreads.
fn spread(&mut self, doc: ObjId, spread: ObjId) -> Result<Option<Spread>, Error> {
let s = self.s;
if !s.is(spread, b"Sprd") {
self.warn("an unknown kind of page");
return Ok(None);
}
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, self.base, 0)?;
if mask.is_some() {
self.warn("mask layers directly on a page (imported without them)");
}
Ok(Some(Spread { bounds, pages, transparent, nodes }))
}
/// The children of `parent`, and the mask layer among them that masks the parent (with its
/// placement), read later over just what it masks.
#[allow(clippy::type_complexity)]
fn children(&mut self, parent: ObjId, world: Affine, depth: usize) -> Result<(Vec<Node>, Option<(ObjId, Affine)>), Error> {
let s = self.s;
let mut out = Vec::new();
let mut mask = None;
@@ -391,10 +630,10 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
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 => {}
if mask.is_none() {
mask = Some((c, local.then(world)));
} else {
self.warn("several pixel masks on one layer (only the first is used)");
}
continue;
}
@@ -442,14 +681,28 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
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 (mut children, child_mask) = if class == Tag::of(b"Comp") { (Vec::new(), None) } else { self.children(id, world, depth)? };
if let Some(embedded) = self.pending.take() {
children.extend(embedded);
}
// Pixel masks are read only over what they mask (they can span a whole spread at print
// resolution, a gigapixel).
let over = match (kind_bounds(&kind), nodes_bounds(&children)) {
(Some(a), Some(b)) => Some(union(a, b)),
(a, b) => a.or(b),
};
let child_mask = match child_mask {
Some((c, at)) => crate::raster::mask(self, c, at, over)?,
None => None,
};
let (mask, attached_mask) = self.attached_masks(id, world, over)?;
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 }))
let mut node = Node { class, name, visible, locked, opacity, blend, kind, mask, pixel_mask, effects: Vec::new(), children };
// Gradient overlays are laid out over the node's bounds.
let bounds = nodes_bounds(std::slice::from_ref(&node));
node.effects = self.effects(id, world, bounds);
Ok(Some(node))
}
fn kind(&mut self, id: ObjId, class: Tag, world: Affine, parent: Affine) -> Result<Kind, Error> {
@@ -471,6 +724,13 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
let (fills, strokes) = paint::node_paint(self, id, world);
Kind::Shape { path: local.transformed(world), fills, strokes, even_odd }
}
b"TxtT" => match crate::text::table(self, id, world) {
Some(t) => Kind::Table(t),
None => {
self.warn("tables that could not be read");
Kind::Unsupported
}
},
b"TxtA" | b"TxtF" | b"TxtC" => match crate::text::read(self, id, world) {
Some(t) => Kind::Text(t),
None => {
@@ -482,15 +742,35 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
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)
b"MPIN" => {
// `SLnk` links the instance with the master spread it shows.
let linked = s.obj(id, b"SLnk").map(|l| s.objs(l, b"ILOb")).unwrap_or_default();
let master = linked.iter().find_map(|o| self.master_ids.iter().position(|m| m == o));
let at = |t: &[u8; 4], default: usize| s.int(id, t).and_then(|v| usize::try_from(v).ok()).unwrap_or(default).min(4096);
match master {
Some(master) => Kind::MasterInstance { master, page: at(b"PgOf", 0), master_page: at(b"MPOf", 0), count: at(b"PgCt", 1) },
None => {
self.warn("master page instances without their master");
Kind::Unsupported
}
}
None => {
self.warn("embedded documents and symbols without a cached picture");
Kind::Unsupported
}
b"EmbN" => match self.embedded_document(id, world) {
// The embedded document itself, as editable content.
Some(nodes) => {
self.pending = Some(nodes);
Kind::Group
}
None => 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.
@@ -515,9 +795,89 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
})
}
/// The enabled layer effects (`FiEf`) of node `id`. The angle convention was measured on the
/// public MIT fx fixture (Patchy): an angle of π/2 with offset 10 shadows 10 below.
fn effects(&mut self, id: ObjId, world: Affine, bounds: Option<Rect>) -> Vec<Effect> {
let s = self.s;
let mut out = Vec::new();
for e in s.objs(id, b"FiEf").into_iter().take(64) {
if s.bool(e, b"Enab") == Some(false) {
continue;
}
// "Scale with object": lengths follow the node's transform; otherwise they are absolute.
let k = if s.bool(e, b"SclO") == Some(true) { world.scale() } else { 1.0 };
let len = |t: &[u8; 4]| s.f64(e, t).filter(|v| v.is_finite() && *v >= 0.0 && *v < 1e6).unwrap_or(0.0) * k;
let opacity = s.f64(e, b"Opac").filter(|v| v.is_finite()).unwrap_or(1.0).clamp(0.0, 1.0);
let angle = s.f64(e, b"Angl").filter(|v| v.is_finite()).unwrap_or(0.0);
let color = s.obj(e, b"Colr").and_then(|c| crate::paint::color(self, c));
let class = s.class(e).map(|c| c.0.to_be_bytes());
match (class.as_ref(), color) {
(Some(b"Shad"), Some(color)) => out.push(Effect::DropShadow { color, opacity, radius: len(b"Radi"), distance: len(b"Offs"), angle }),
(Some(b"InnS"), Some(color)) => out.push(Effect::InnerShadow { color, opacity, radius: len(b"Radi"), distance: len(b"Offs"), angle }),
(Some(b"OutG"), Some(color)) => out.push(Effect::OuterGlow { color, opacity, radius: len(b"Radi") }),
(Some(b"Strk"), Some(color)) if s.enumeration(e, b"Ftyp").is_none_or(|(t, _)| t == 0) => {
let align = s.enumeration(e, b"Alig").map_or(0, |(a, _)| a);
out.push(Effect::Outline { color, opacity, width: len(b"Radi"), align });
}
(Some(b"Strk"), _) => self.warn("gradient outline effects (left out)"),
(Some(b"BevE" | b"EmbE"), _) => {
// No radius, no bevel (as Affinity draws it).
let size = len(b"Radi");
if size > 0.0 {
let white = crate::paint::Color::Rgb { r: 1.0, g: 1.0, b: 1.0, a: 1.0 };
let black = crate::paint::Color::Rgb { r: 0.0, g: 0.0, b: 0.0, a: 1.0 };
let highlight = s.obj(e, b"HiCl").and_then(|c| crate::paint::color(self, c)).unwrap_or(white);
let shadow = s.obj(e, b"ShCl").and_then(|c| crate::paint::color(self, c)).unwrap_or(black);
let shadow_opacity = s.f64(e, b"ShOp").filter(|v| v.is_finite()).unwrap_or(0.75).clamp(0.0, 1.0);
let depth = s.f64(e, b"Dept").filter(|v| v.is_finite()).unwrap_or(5.0);
let azimuth = s.f64(e, b"Azim").filter(|v| v.is_finite()).unwrap_or(std::f64::consts::FRAC_PI_4 * 3.0);
let inner = s.enumeration(e, b"Beve").is_none_or(|(k, _)| k == 0);
out.push(Effect::Bevel { size, depth, angle: azimuth, highlight, highlight_opacity: opacity, shadow, shadow_opacity, inner });
}
}
(Some(b"ColO"), Some(color)) => {
let blend = self.effect_blend(e);
out.push(Effect::ColorOverlay { color, opacity, blend });
}
(Some(b"GrdO"), _) => {
let blend = self.effect_blend(e);
// The gradient's unit space spans the node's bounding box from -1 to 1, centred
// on it (y down): checked against the stored previews of real documents.
let space =
bounds.map(|b| Affine([(b.x1 - b.x0) / 2.0, 0.0, 0.0, (b.y1 - b.y0) / 2.0, (b.x0 + b.x1) / 2.0, (b.y0 + b.y1) / 2.0]));
match (s.obj(e, b"GrFl"), space) {
(Some(fill), Some(space)) => match paint::descriptor(self, fill, space) {
Some(Paint::Gradient(gradient)) => out.push(Effect::GradientOverlay { gradient, opacity, blend }),
Some(Paint::Solid(color)) => out.push(Effect::ColorOverlay { color, opacity, blend }),
_ => {}
},
// Nothing visible to lay it over.
(Some(_), None) => {}
(None, _) => self.warn("gradient overlay effects that could not be read (left out)"),
}
}
(Some(b"ColO"), None) => self.warn("colour overlay effects that could not be read (left out)"),
(Some(b"Gaus"), _) => self.warn("blur effects (left out)"),
_ => self.warn("layer effects of other kinds (left out)"),
}
}
out
}
/// A layer effect's blend mode (`BlnM`); unknown modes are Normal (warned).
fn effect_blend(&mut self, e: ObjId) -> Blend {
match self.s.enumeration(e, b"BlnM") {
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
}),
}
}
/// 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> {
fn attached_masks(&mut self, id: ObjId, world: Affine, over: Option<Rect>) -> Result<(Option<Path>, Option<Image>), Error> {
let s = self.s;
let attached = s.objs(id, b"AdCh");
let mut mask: Option<Path> = None;
@@ -534,7 +894,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
}
} 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)? {
match crate::raster::mask(self, a, local, over)? {
Some(m) if pixels.is_none() => pixels = Some(m),
Some(_) => self.warn("several pixel masks on one layer (only the first is used)"),
None => {}
@@ -546,6 +906,68 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
Ok((mask, pixels))
}
/// The content of an Affinity document embedded in this one (`EmbN` whose `EmbC` entry holds
/// `EmDc`, four more bytes and a complete Affinity document): its first spread's nodes,
/// placed so the centre of its page, first artboard or content is the node's origin.
fn embedded_document(&mut self, id: ObjId, world: Affine) -> Option<Vec<Node>> {
let s = self.s;
let entry = s.obj(id, b"Bitm").and_then(|b| s.obj(b, b"EmCn")).and_then(|c| s.entry(c, b"EmbC"))?.to_string();
if entry.is_empty() {
return None;
}
if self.depth >= MAX_EMBED_DEPTH {
self.warn("embedded documents nested more than four deep");
return None;
}
let data = self.archive.read(&entry).ok()?;
let inner = data.strip_prefix(b"EmDc").and_then(|d| d.get(4..))?;
if !crate::is_affinity(inner) {
return None;
}
// A first reading finds its page and resolution, the second places it.
let probe = match read_at(inner, self.archive.remaining(), self.pixels_left, Affine::IDENTITY, self.depth + 1) {
Ok(r) => r,
Err(e) => {
self.warn(format!("embedded documents that could not be read ({e})"));
return None;
}
};
let used = probe.extracted;
let spread = probe.document.spreads.first()?;
// `PBBx` 2 places the content's own bounds (as with a cached picture); otherwise its page.
let content = s.obj(id, b"Bitm").and_then(|b| s.enumeration(b, b"PBBx")).is_some_and(|(k, _)| k == 2);
let first_board = spread.nodes.iter().find_map(|n| if let Kind::Artboard { rect, .. } = &n.kind { Some(*rect) } else { None });
let page = if content { None } else { spread.pages.first().copied().or(first_board) };
let page = page.or_else(|| nodes_bounds(&spread.nodes)).unwrap_or(spread.bounds);
// Its document pixels are the node's units, whatever either document's resolution.
let k = 1.0;
let (cx, cy) = ((page.x0 + page.x1) / 2.0, (page.y0 + page.y1) / 2.0);
let base = Affine([k, 0.0, 0.0, k, -cx * k, -cy * k]).then(world);
if !base.is_finite() {
self.warn("embedded documents with an invalid placement");
return None;
}
if self.archive.charge(used).is_err() {
self.warn("embedded documents beyond the size limit (left out)");
return None;
}
let placed = read_at(inner, self.archive.remaining(), self.pixels_left, base, self.depth + 1).ok()?;
// The probe's pixels were only looked at; the placed content's stay.
self.pixels_left = placed.pixels_left;
let doc = placed.document;
if self.archive.charge(placed.extracted).is_err() {
self.warn("embedded documents beyond the size limit (left out)");
return None;
}
for w in doc.warnings {
self.warn(format!("{w} [in embedded documents]"));
}
if doc.spreads.len() > 1 {
self.warn("embedded documents with several pages (the first is used)");
}
doc.spreads.into_iter().next().map(|s| s.nodes)
}
fn artboard(&mut self, id: ObjId, world: Affine) -> Kind {
let s = self.s;
let Some(b) = s.floats::<4>(id, b"ShpB").map(rect).filter(|b| b.x1 > b.x0 && b.y1 > b.y0) else {
@@ -580,6 +1002,64 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
}
}
/// The bounds of what visible `nodes` draw (control points of outlines, corners of images, frames
/// and tables), in document pixels.
pub fn nodes_bounds(nodes: &[Node]) -> Option<Rect> {
fn walk(nodes: &[Node], depth: usize, out: &mut Option<Rect>) {
if depth > MAX_TREE_DEPTH {
return;
}
for n in nodes.iter().filter(|n| n.visible) {
kind_points(&n.kind, &mut |p| add_point(out, p));
walk(&n.children, depth + 1, out);
}
}
let mut out = None;
walk(nodes, 0, &mut out);
out
}
/// The bounds of what one node's own kind draws (not its children).
pub(crate) fn kind_bounds(kind: &Kind) -> Option<Rect> {
let mut out = None;
kind_points(kind, &mut |p| add_point(&mut out, p));
out
}
fn add_point(out: &mut Option<Rect>, p: Point) {
if p.x.is_finite() && p.y.is_finite() {
let r = Rect { x0: p.x, y0: p.y, x1: p.x, y1: p.y };
*out = Some(out.map_or(r, |o| union(o, r)));
}
}
fn kind_points(kind: &Kind, add: &mut dyn FnMut(Point)) {
let corners = |r: Rect, m: Affine| [(r.x0, r.y0), (r.x1, r.y0), (r.x1, r.y1), (r.x0, r.y1)].map(|(x, y)| m.apply(Point { x, y }));
match kind {
Kind::Shape { path, .. } | Kind::Artboard { path, .. } => {
for sp in &path.subpaths {
add(sp.start);
for seg in &sp.segments {
seg.iter().for_each(|p| add(*p));
}
}
}
Kind::Image(img) => {
corners(Rect { x0: 0.0, y0: 0.0, x1: f64::from(img.width), y1: f64::from(img.height) }, img.transform).into_iter().for_each(add)
}
Kind::Text(t) => match t.frame {
Some(f) => corners(f, t.transform).into_iter().for_each(add),
None => add(t.transform.apply(t.anchor)),
},
Kind::Table(t) => {
if let (Some(&x0), Some(&x1), Some(&y0), Some(&y1)) = (t.columns.first(), t.columns.last(), t.rows.first(), t.rows.last()) {
corners(Rect { x0, y0, x1, y1 }, t.transform).into_iter().for_each(add);
}
}
Kind::Layer | Kind::Group | Kind::Unsupported | Kind::MasterInstance { .. } => {}
}
}
/// 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> {
+543
View File
@@ -0,0 +1,543 @@
//! Colour and gradient overlays. VectorCraft has no such effect, so an overlay is baked into what
//! its node paints: fill, stroke and text colours, and pixels. The overlay lies over the node's
//! own paint in its blend mode at its opacity, inside the node's alpha (which it keeps).
//!
//! Blend modes follow the W3C Compositing and Blending formulas; the modes PDF lacks (Vivid
//! Light, Linear Light, Pin Light, Hard Mix, Add, Subtract, Average, Negation, Reflect, Glow,
//! Darker/Lighter Color) use their usual definitions.
use std::borrow::Cow;
use crate::model::{Affine, Blend, Effect, Image, Kind, Node, Pixels, Point};
use crate::paint::{Color, Gradient, GradientKind, Paint, Stop, Stroke};
/// Deepest subtree an overlay is baked into (the reader bounds trees well below this).
const MAX_DEPTH: usize = 256;
/// Most pixels an overlay is baked into per image.
const MAX_PIXELS: usize = 64 << 20;
#[derive(Clone, Copy)]
enum Source<'a> {
Solid(Color),
Gradient(&'a Gradient),
}
#[derive(Clone, Copy)]
struct Overlay<'a> {
source: Source<'a>,
opacity: f64,
blend: Blend,
}
/// `node` with its colour and gradient overlays baked in (borrowed unchanged when it has none).
/// `warn` hears what could only be approximated.
pub fn apply<'a>(node: &'a Node, warn: &mut dyn FnMut(&str)) -> Cow<'a, Node> {
let overlays: Vec<Overlay> = node
.effects
.iter()
.filter_map(|e| match e {
Effect::ColorOverlay { color, opacity, blend } => Some(Overlay { source: Source::Solid(*color), opacity: *opacity, blend: *blend }),
Effect::GradientOverlay { gradient, opacity, blend } => {
Some(Overlay { source: Source::Gradient(gradient), opacity: *opacity, blend: *blend })
}
_ => None,
})
.filter(|o| o.opacity > 0.0)
.collect();
if overlays.is_empty() {
return Cow::Borrowed(node);
}
let mut out = node.clone();
out.effects.retain(|e| !matches!(e, Effect::ColorOverlay { .. } | Effect::GradientOverlay { .. }));
for o in &overlays {
if matches!(o.blend, Blend::Erase | Blend::PassThrough) {
warn("overlay effects in a blend mode VectorCraft doesn't have (imported as Normal)");
}
bake_node(&mut out, o, 0, warn);
}
Cow::Owned(out)
}
fn bake_node(node: &mut Node, o: &Overlay, depth: usize, warn: &mut dyn FnMut(&str)) {
if depth > MAX_DEPTH {
return;
}
match &mut node.kind {
Kind::Shape { fills, strokes, .. } => {
for f in fills.iter_mut() {
*f = paint(f, o, warn);
}
strokes.iter_mut().for_each(|s| stroke(s, o, warn));
}
Kind::Artboard { background, strokes, .. } => {
for f in background.iter_mut() {
*f = paint(f, o, warn);
}
strokes.iter_mut().for_each(|s| stroke(s, o, warn));
}
Kind::Text(t) => {
for r in &mut t.runs {
r.fill = paint(&r.fill, o, warn);
if let Some(s) = &mut r.stroke {
stroke(s, o, warn);
}
}
}
Kind::Table(t) => {
for c in &mut t.cells {
for r in &mut c.runs {
r.fill = paint(&r.fill, o, warn);
}
}
for s in t.vertical.iter_mut().chain(t.horizontal.iter_mut()).flatten() {
stroke(s, o, warn);
}
}
Kind::Image(img) => {
if !pixels(img, o) {
warn("overlay effects on images too large to recolour (left out)");
}
}
Kind::Layer | Kind::Group | Kind::Unsupported | Kind::MasterInstance { .. } => {}
}
for e in &mut node.effects {
match e {
Effect::Outline { color, .. } => *color = over(*color, o.source.at_solid(), o),
Effect::DropShadow { .. } | Effect::InnerShadow { .. } | Effect::OuterGlow { .. } | Effect::Bevel { .. } => {}
Effect::ColorOverlay { .. } | Effect::GradientOverlay { .. } => {}
}
}
for c in &mut node.children {
bake_node(c, o, depth + 1, warn);
}
}
fn stroke(s: &mut Stroke, o: &Overlay, warn: &mut dyn FnMut(&str)) {
s.paint = paint(&s.paint, o, warn);
}
/// A paint with the overlay over it.
fn paint(p: &Paint, o: &Overlay, warn: &mut dyn FnMut(&str)) -> Paint {
match (p, o.source) {
(Paint::None, _) => Paint::None,
(Paint::Solid(c), Source::Solid(s)) => Paint::Solid(over(*c, s, o)),
(Paint::Gradient(g), Source::Solid(s)) => {
Paint::Gradient(Gradient { stops: g.stops.iter().map(|st| Stop { color: over(st.color, s, o), ..*st }).collect(), ..g.clone() })
}
// Over one colour, the result is the overlay's gradient with each stop laid over it.
(Paint::Solid(c), Source::Gradient(g)) => {
Paint::Gradient(Gradient { stops: g.stops.iter().map(|st| Stop { color: over(*c, st.color, o), ..*st }).collect(), ..g.clone() })
}
(Paint::Gradient(base), Source::Gradient(g)) => {
if !(o.blend == Blend::Normal && o.opacity >= 1.0) {
warn("gradient overlays on gradients (approximated)");
}
// Exact when the overlay covers it; otherwise over the base gradient's average colour.
let avg = average(base);
Paint::Gradient(Gradient { stops: g.stops.iter().map(|st| Stop { color: over(avg, st.color, o), ..*st }).collect(), ..g.clone() })
}
}
}
impl Source<'_> {
/// One colour for the source (a gradient's average) where only one fits.
fn at_solid(&self) -> Color {
match self {
Source::Solid(c) => *c,
Source::Gradient(g) => average(g),
}
}
}
fn average(g: &Gradient) -> Color {
let n = g.stops.len().max(1) as f64;
let (mut r, mut gr, mut b, mut a) = (0.0, 0.0, 0.0, 0.0);
for s in &g.stops {
let [sr, sg, sb] = rgb(s.color);
r += sr;
gr += sg;
b += sb;
a += s.color.alpha();
}
Color::Rgb { r: r / n, g: gr / n, b: b / n, a: a / n }
}
/// `source` laid over `base` in the overlay's mode and opacity; `base`'s alpha is kept.
fn over(base: Color, source: Color, o: &Overlay) -> Color {
let k = (o.opacity * source.alpha()).clamp(0.0, 1.0);
let alpha = base.alpha();
// Normal mixes within the colour model when both share it, so CMYK stays CMYK.
if matches!(o.blend, Blend::Normal | Blend::PassThrough | Blend::Erase) {
let mix = |a: f64, b: f64| a + (b - a) * k;
match (base, source) {
(Color::Cmyk { c, m, y, k: kk, .. }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2, .. }) => {
return Color::Cmyk { c: mix(c, c2), m: mix(m, m2), y: mix(y, y2), k: mix(kk, k2), a: alpha };
}
(Color::Gray { v, .. }, Color::Gray { v: v2, .. }) => return Color::Gray { v: mix(v, v2), a: alpha },
(_, s) if k >= 1.0 => return with_alpha(s, alpha),
_ => {}
}
}
let b = rgb(base);
let s = rgb(source);
let blended = blend(o.blend, b, s);
let [r, g, bl] = [0, 1, 2].map(|i| b[i] + (blended[i] - b[i]) * k);
Color::Rgb { r, g, b: bl, a: alpha }
}
fn with_alpha(c: Color, a: f64) -> Color {
match c {
Color::Rgb { r, g, b, .. } => Color::Rgb { r, g, b, a },
Color::Cmyk { c, m, y, k, .. } => Color::Cmyk { c, m, y, k, a },
Color::Gray { v, .. } => Color::Gray { v, a },
Color::Lab { l, a: la, b, .. } => Color::Lab { l, a: la, b, alpha: a },
}
}
/// sRGB components (0..1) of a colour. CMYK uses the profile-free formula (the engine shows the
/// node's other CMYK colours through the document's profile), Lab its D50 sRGB colour.
fn rgb(c: Color) -> [f64; 3] {
let f = |v: f64| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 };
match c {
Color::Rgb { r, g, b, .. } => [f(r), f(g), f(b)],
Color::Gray { v, .. } => [f(v); 3],
Color::Cmyk { c, m, y, k, .. } => {
let w = 1.0 - f(k);
[(1.0 - f(c)) * w, (1.0 - f(m)) * w, (1.0 - f(y)) * w]
}
Color::Lab { l, a, b, .. } => lab_to_srgb(l, a, b).map(f),
}
}
/// CIE L*a*b* (D50) to sRGB, through XYZ and the Bradford adaptation to D65.
fn lab_to_srgb(l: f64, a: f64, b: f64) -> [f64; 3] {
let finite = |v: f64| if v.is_finite() { v } else { 0.0 };
let (l, a, b) = (finite(l), finite(a), finite(b));
let fy = (l + 16.0) / 116.0;
let (fx, fz) = (fy + a / 500.0, fy - b / 200.0);
let inv = |t: f64| if t > 6.0 / 29.0 { t * t * t } else { 3.0 * (6.0f64 / 29.0).powi(2) * (t - 4.0 / 29.0) };
// D50 white.
let (x, y, z) = (0.964_22 * inv(fx), inv(fy), 0.825_21 * inv(fz));
// XYZ (D50) to linear sRGB, Bradford-adapted.
let r = 3.133_856 * x - 1.616_867 * y - 0.490_615 * z;
let g = -0.978_768 * x + 1.916_142 * y + 0.033_454 * z;
let bl = 0.071_945 * x - 0.228_991 * y + 1.405_243 * z;
let gamma = |v: f64| if v <= 0.003_130_8 { 12.92 * v } else { 1.055 * v.max(0.0).powf(1.0 / 2.4) - 0.055 };
[gamma(r), gamma(g), gamma(bl)]
}
/// Bake the overlay into an image's pixels. False if they couldn't be read or are too many.
fn pixels(img: &mut Image, o: &Overlay) -> bool {
let Some(n) = (img.width as usize).checked_mul(img.height as usize).filter(|n| *n <= MAX_PIXELS) else { return false };
let Some(len) = n.checked_mul(4) else { return false };
let mut px = match &img.pixels {
Pixels::Rgba8(p) => match p.get(..len) {
Some(p) => p.to_vec(),
None => return false,
},
Pixels::Encoded(bytes) => match image::load_from_memory(bytes) {
Ok(d) if d.width() == img.width && d.height() == img.height => d.to_rgba8().into_raw(),
_ => return false,
},
// An overlay is blended in RGB: CMYK pixels take the profile-free formula here.
Pixels::Cmyka8(p) => match p.get(..n.saturating_mul(5)) {
Some(p) if p.len() == n * 5 => p
.as_chunks::<5>()
.0
.iter()
.flat_map(|[c, m, y, k, a]| {
let w = 255 - u16::from(*k);
let ch = |v: u8| ((u16::from(255 - v) * w + 127) / 255) as u8;
[ch(*c), ch(*m), ch(*y), *a]
})
.collect(),
_ => return false,
},
};
let width = img.width.max(1) as usize;
// A solid source is converted once; a gradient is sampled at each pixel's centre.
let solid = match o.source {
Source::Solid(c) => Some((rgb(c), c.alpha())),
Source::Gradient(_) => None,
};
let to_unit = match o.source {
Source::Gradient(g) => match invert(g.transform) {
Some(m) => Some((img.transform.then(m), g)),
None => return false,
},
Source::Solid(_) => None,
};
for (i, p) in px.as_chunks_mut::<4>().0.iter_mut().enumerate() {
if p[3] == 0 {
continue;
}
let (s, sa) = match (solid, &to_unit) {
(Some(s), _) => s,
(None, Some((m, g))) => {
let q = m.apply(Point { x: (i % width) as f64 + 0.5, y: (i / width) as f64 + 0.5 });
let c = sample(g, q);
(rgb(c), c.alpha())
}
(None, None) => continue,
};
let k = (o.opacity * sa).clamp(0.0, 1.0);
let b = [p[0], p[1], p[2]].map(|v| f64::from(v) / 255.0);
let m = blend(o.blend, b, s);
for c in 0..3 {
p[c] = ((b[c] + (m[c] - b[c]) * k).clamp(0.0, 1.0) * 255.0).round() as u8;
}
}
img.pixels = Pixels::Rgba8(px);
true
}
/// The gradient's colour at unit-space point `q`.
fn sample(g: &Gradient, q: Point) -> Color {
let t = match g.kind {
GradientKind::Linear => q.x,
GradientKind::Radial => q.x.hypot(q.y),
GradientKind::Conical => q.y.atan2(q.x).rem_euclid(std::f64::consts::TAU) / std::f64::consts::TAU,
};
let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 };
let (Some(first), Some(last)) = (g.stops.first(), g.stops.last()) else { return Color::Rgb { r: 0.0, g: 0.0, b: 0.0, a: 0.0 } };
if t <= first.offset {
return first.color;
}
for w in g.stops.windows(2) {
let [a, b] = [&w[0], &w[1]];
if t <= b.offset {
let span = b.offset - a.offset;
let u = if span > 1e-9 { (t - a.offset) / span } else { 1.0 };
// Affinity's midpoint bias (see `Stop::half_point`).
let k = 1.0 + 8.0 * (a.bias - 0.5).abs();
let u = if a.bias >= 0.5 { u.powf(k) } else { 1.0 - (1.0 - u).powf(k) };
let (x, y) = (rgb(a.color), rgb(b.color));
let [r, g, bl] = [0, 1, 2].map(|i| x[i] + (y[i] - x[i]) * u);
return Color::Rgb { r, g, b: bl, a: a.color.alpha() + (b.color.alpha() - a.color.alpha()) * u };
}
}
last.color
}
fn invert(m: Affine) -> Option<Affine> {
let [a, b, c, d, e, f] = m.0;
let det = a * d - b * c;
if !(det.is_finite() && det.abs() > 1e-12) {
return None;
}
let (ia, ib, ic, id) = (d / det, -b / det, -c / det, a / det);
Some(Affine([ia, ib, ic, id, -(ia * e + ic * f), -(ib * e + id * f)]))
}
/// The blend of backdrop `b` and source `s` (sRGB, 0..1).
fn blend(mode: Blend, b: [f64; 3], s: [f64; 3]) -> [f64; 3] {
use Blend::*;
let sep = |f: fn(f64, f64) -> f64| [0, 1, 2].map(|i| f(b[i], s[i]).clamp(0.0, 1.0));
match mode {
Normal | PassThrough | Erase => s,
Darken => sep(f64::min),
Lighten => sep(f64::max),
Multiply => sep(|b, s| b * s),
Screen => sep(screen),
ColorBurn => sep(burn),
ColorDodge => sep(dodge),
Blend::Overlay => sep(|b, s| hard_light(s, b)),
HardLight => sep(hard_light),
SoftLight => sep(soft_light),
Difference => sep(|b, s| (b - s).abs()),
Exclusion => sep(|b, s| b + s - 2.0 * b * s),
Add => sep(|b, s| b + s),
Subtract => sep(|b, s| b - s),
VividLight => sep(|b, s| if s <= 0.5 { burn(b, 2.0 * s) } else { dodge(b, 2.0 * s - 1.0) }),
LinearLight => sep(|b, s| b + 2.0 * s - 1.0),
PinLight => sep(|b, s| if s <= 0.5 { b.min(2.0 * s) } else { b.max(2.0 * s - 1.0) }),
HardMix => sep(|b, s| if b + s >= 1.0 { 1.0 } else { 0.0 }),
Average => sep(|b, s| (b + s) / 2.0),
Negation => sep(|b, s| 1.0 - (1.0 - b - s).abs()),
Reflect => sep(reflect),
Glow => sep(|b, s| reflect(s, b)),
DarkerColor => {
if lum(s) < lum(b) {
s
} else {
b
}
}
LighterColor => {
if lum(s) > lum(b) {
s
} else {
b
}
}
Hue => set_lum(set_sat(s, sat(b)), lum(b)),
Saturation => set_lum(set_sat(b, sat(s)), lum(b)),
Blend::Color => set_lum(s, lum(b)),
Luminosity => set_lum(b, lum(s)),
}
}
fn screen(b: f64, s: f64) -> f64 {
b + s - b * s
}
fn burn(b: f64, s: f64) -> f64 {
if b >= 1.0 {
1.0
} else if s <= 0.0 {
0.0
} else {
1.0 - ((1.0 - b) / s).min(1.0)
}
}
fn dodge(b: f64, s: f64) -> f64 {
if b <= 0.0 {
0.0
} else if s >= 1.0 {
1.0
} else {
(b / (1.0 - s)).min(1.0)
}
}
fn hard_light(b: f64, s: f64) -> f64 {
if s <= 0.5 { b * 2.0 * s } else { screen(b, 2.0 * s - 1.0) }
}
fn soft_light(b: f64, s: f64) -> f64 {
if s <= 0.5 {
b - (1.0 - 2.0 * s) * b * (1.0 - b)
} else {
let d = if b <= 0.25 { ((16.0 * b - 12.0) * b + 4.0) * b } else { b.sqrt() };
b + (2.0 * s - 1.0) * (d - b)
}
}
fn reflect(b: f64, s: f64) -> f64 {
if s >= 1.0 { 1.0 } else { (b * b / (1.0 - s)).min(1.0) }
}
fn lum(c: [f64; 3]) -> f64 {
0.3 * c[0] + 0.59 * c[1] + 0.11 * c[2]
}
fn clip_color(c: [f64; 3]) -> [f64; 3] {
let l = lum(c);
let n = c[0].min(c[1]).min(c[2]);
let x = c[0].max(c[1]).max(c[2]);
c.map(|v| {
let mut v = v;
if n < 0.0 && l - n > 1e-12 {
v = l + (v - l) * l / (l - n);
}
if x > 1.0 && x - l > 1e-12 {
v = l + (v - l) * (1.0 - l) / (x - l);
}
v.clamp(0.0, 1.0)
})
}
fn set_lum(c: [f64; 3], l: f64) -> [f64; 3] {
let d = l - lum(c);
clip_color(c.map(|v| v + d))
}
fn sat(c: [f64; 3]) -> f64 {
c[0].max(c[1]).max(c[2]) - c[0].min(c[1]).min(c[2])
}
fn set_sat(c: [f64; 3], s: f64) -> [f64; 3] {
let max = c[0].max(c[1]).max(c[2]);
let min = c[0].min(c[1]).min(c[2]);
if max - min <= 1e-12 {
return [0.0; 3];
}
c.map(|v| (v - min) * s / (max - min))
}
#[cfg(test)]
mod tests {
use super::*;
fn solid(c: Color, opacity: f64, blend: Blend) -> Node {
Node {
class: crate::stream::Tag::of(b"ShpN"),
name: String::new(),
visible: true,
locked: false,
opacity: 1.0,
blend: Blend::Normal,
kind: Kind::Shape {
path: Default::default(),
fills: vec![Paint::Solid(Color::Rgb { r: 1.0, g: 0.5, b: 0.0, a: 1.0 })],
strokes: vec![],
even_odd: false,
},
mask: None,
pixel_mask: None,
effects: vec![Effect::ColorOverlay { color: c, opacity, blend }],
children: vec![],
}
}
fn fill(n: &Node) -> Color {
match &n.kind {
Kind::Shape { fills, .. } => match fills.first() {
Some(Paint::Solid(c)) => *c,
other => panic!("{other:?}"),
},
other => panic!("{other:?}"),
}
}
#[test]
fn a_full_colour_overlay_replaces_the_fill_and_keeps_its_alpha() {
let white = Color::Rgb { r: 1.0, g: 1.0, b: 1.0, a: 1.0 };
let n = apply(&solid(white, 1.0, Blend::Normal), &mut |_| {}).into_owned();
assert_eq!(fill(&n), white);
assert!(n.effects.is_empty(), "the overlay is baked, not kept");
// Half way, mixed in RGB.
let n = apply(&solid(white, 0.5, Blend::Normal), &mut |_| {}).into_owned();
assert_eq!(fill(&n), Color::Rgb { r: 1.0, g: 0.75, b: 0.5, a: 1.0 });
}
#[test]
fn blend_modes_follow_their_formulas() {
let b = [0.25, 0.5, 1.0];
assert_eq!(blend(Blend::Multiply, b, [0.5; 3]), [0.125, 0.25, 0.5]);
assert_eq!(blend(Blend::Screen, b, [0.5; 3]), [0.625, 0.75, 1.0]);
assert_eq!(blend(Blend::VividLight, [0.5; 3], [0.75; 3]), [1.0; 3]);
assert_eq!(blend(Blend::HardMix, [0.5; 3], [0.6; 3]), [1.0; 3]);
// Color keeps the backdrop's luminosity with the source's hue and saturation.
let c = blend(Blend::Color, [0.5; 3], [1.0, 0.0, 0.0]);
assert!((lum(c) - 0.5).abs() < 1e-9 && c[0] > c[1] && (c[1] - c[2]).abs() < 1e-9, "{c:?}");
}
#[test]
fn overlays_recolour_pixels_inside_their_alpha() {
let mut n = solid(Color::Rgb { r: 0.0, g: 0.0, b: 1.0, a: 1.0 }, 1.0, Blend::Normal);
n.kind = Kind::Image(Image { width: 2, height: 1, pixels: Pixels::Rgba8(vec![255, 0, 0, 255, 255, 0, 0, 0]), transform: Affine::IDENTITY });
let out = apply(&n, &mut |_| {}).into_owned();
let Kind::Image(img) = &out.kind else { panic!() };
assert_eq!(img.pixels, Pixels::Rgba8(vec![0, 0, 255, 255, 255, 0, 0, 0]));
}
#[test]
fn a_gradient_overlay_over_one_colour_becomes_that_gradient() {
let g = Gradient {
kind: GradientKind::Linear,
stops: vec![
Stop { offset: 0.0, color: Color::Rgb { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }, bias: 0.5 },
Stop { offset: 1.0, color: Color::Rgb { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }, bias: 0.5 },
],
transform: Affine([10.0, 0.0, 0.0, 10.0, 0.0, 0.0]),
};
let mut n = solid(Color::Rgb { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }, 1.0, Blend::Normal);
n.effects = vec![Effect::GradientOverlay { gradient: g.clone(), opacity: 1.0, blend: Blend::Normal }];
let out = apply(&n, &mut |_| {}).into_owned();
let Kind::Shape { fills, .. } = &out.kind else { panic!() };
assert_eq!(fills.first(), Some(&Paint::Gradient(g)));
}
}
+8 -1
View File
@@ -283,7 +283,7 @@ pub(crate) fn node_paint(r: &mut Reader, id: ObjId, local: Affine) -> (Vec<Paint
(fills, stroke.into_iter().collect())
}
fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, behind: bool) -> Option<Stroke> {
pub(crate) 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 {
@@ -295,6 +295,13 @@ fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, behind: bool)
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();
// Byte 10 is the stroke's style: 0 none, 1 solid, 2 dashed, 3 a brush. A shape without a
// stroke still stores one (black, 1 px wide), with style 0. Identified on two documents their
// owner made, where every such stroke is invisible in Affinity's own PDF export, and checked
// against the public corpus's thumbnails.
if data.get(10) == Some(&0) {
return None;
}
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
+244 -57
View File
@@ -39,6 +39,9 @@ pub(crate) fn node_image(r: &mut Reader, id: ObjId, world: Affine) -> Result<Opt
&& (s.is(id, b"ImgN") || !has_tiles)
{
match original(r, name) {
// HEIF/HEIC (phone photos) and AVIF originals can't be decoded here: the same pixels
// stored as tiles are used instead (see `decode` when the tiles point back at the original).
Ok(bytes) if is_heif(&bytes) && has_tiles => {}
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)"),
@@ -66,8 +69,17 @@ pub(crate) fn node_image(r: &mut Reader, id: ObjId, world: Affine) -> Result<Opt
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> {
/// An ISO base media file holding a HEIF image (HEIC, AVIF): `ftyp` with an image brand.
fn is_heif(bytes: &[u8]) -> bool {
bytes.get(4..8) == Some(b"ftyp".as_slice())
&& bytes.get(8..12).is_some_and(|brand| {
[b"heic", b"heix", b"hevc", b"hevx", b"heim", b"heis", b"mif1", b"msf1", b"avif", b"avis"].iter().any(|b| brand == b.as_slice())
})
}
/// A mask layer (`MRst`): one channel of coverage as grey pixels (white shows, black hides), read
/// over `over` (document pixels) when given; outside its pixels a mask hides everything.
pub(crate) fn mask(r: &mut Reader, id: ObjId, world: Affine, over: Option<crate::model::Rect>) -> 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);
@@ -76,7 +88,41 @@ pub(crate) fn mask(r: &mut Reader, id: ObjId, world: Affine) -> Result<Option<Im
r.warn("pixel masks in an unknown format (imported without them)");
return Ok(None);
}
decode(r, bitmap, (0, 0, width, height), world)
let crop = match over.map(|o| crop_to(o, world, width, height)) {
Some(Some(c)) => c,
// What it masks lies outside it: all hidden, which one black pixel says as well.
Some(None) => return Ok(Some(Image { width: 1, height: 1, pixels: Pixels::Rgba8(vec![0, 0, 0, 255]), transform: world })),
None => (0, 0, width, height),
};
decode(r, bitmap, crop, world)
}
/// The bitmap pixels (x0, y0, x1, y1) of a `width` × `height` bitmap placed by `world` that cover
/// document rectangle `over`, one pixel wider all round; `None` if they don't overlap.
fn crop_to(over: crate::model::Rect, world: Affine, width: u32, height: u32) -> Option<(u32, u32, u32, u32)> {
let inv = invert(world)?;
let pts = [(over.x0, over.y0), (over.x1, over.y0), (over.x1, over.y1), (over.x0, over.y1)].map(|(x, y)| inv.apply(crate::model::Point { x, y }));
let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
for p in pts {
if !(p.x.is_finite() && p.y.is_finite()) {
return Some((0, 0, width, height));
}
(x0, y0, x1, y1) = (x0.min(p.x), y0.min(p.y), x1.max(p.x), y1.max(p.y));
}
let clamp = |v: f64, max: u32| v.clamp(0.0, f64::from(max)) as u32;
let (cx0, cy0, cx1, cy1) =
(clamp(x0.floor() - 1.0, width), clamp(y0.floor() - 1.0, height), clamp(x1.ceil() + 1.0, width), clamp(y1.ceil() + 1.0, height));
(cx1 > cx0 && cy1 > cy0).then_some((cx0, cy0, cx1, cy1))
}
fn invert(m: Affine) -> Option<Affine> {
let [a, b, c, d, e, f] = m.0;
let det = a * d - b * c;
if !(det.is_finite() && det.abs() > 1e-12) {
return None;
}
let (ia, ib, ic, id) = (d / det, -b / det, -c / det, a / det);
Some(Affine([ia, ib, ic, id, -(ia * e + ic * f), -(ib * e + id * f)]))
}
/// An embedded document or symbol instance (`EmbN`), as the picture of it Affinity caches.
@@ -99,18 +145,29 @@ pub(crate) fn embedded(r: &mut Reader, id: ObjId, world: Affine) -> Result<Optio
/// 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> {
decode_within(r, bitmap, crop, world, MAX_PIXELS)
}
/// [`decode`] with at most `max_pixels` decoded pixels.
fn decode_within(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affine, max_pixels: u64) -> 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);
let pixels = u64::from(w) * u64::from(h);
if pixels > MAX_PIXELS {
r.warn("pixel layers larger than 64 megapixels");
return Ok(None);
// Larger layers (mostly masks of whole spreads) are read at a half, a quarter… of their
// resolution: every `step`th pixel of every `step`th row.
let mut step = 1u32;
while u64::from(w.div_ceil(step)) * u64::from(h.div_ceil(step)) > max_pixels {
step *= 2;
if step > 1 << 12 {
r.warn("pixel layers too large to read (left out)");
return Ok(None);
}
}
// Before anything is allocated: a layer's tiles can be flat colour, with nothing to extract.
let pixels = u64::from(w.div_ceil(step)) * u64::from(h.div_ceil(step));
let Some(left) = r.pixels_left.checked_sub(pixels) else {
r.warn("pixel layers beyond the pixels one document may hold");
return Ok(None);
@@ -122,10 +179,7 @@ fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affi
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)
}
Some(4) => (5, 1),
_ => {
r.warn("pixel layers in grey, Lab or 32-bit formats");
return Ok(None);
@@ -152,11 +206,48 @@ fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affi
} else {
None
};
// Tiles kept only in an original that can't be decoded here are left empty (transparent). Where
// a tile's colour is in the original but its alpha is stored, as in CMYK layers, it would come
// out opaque white instead: such a layer (a HEIC photo in a CMYK document) is left out, and said.
if needs_source && source.is_none() && channels > 1 {
let states = |c: usize| match s.field(bitmap, &tag(b"Sta", c)) {
Some(Value::Array(a)) => a.clone(),
_ => Vec::new(),
};
let alpha = states(channels);
let blank = (1..channels).any(|c| states(c).iter().enumerate().any(|(i, v)| *v == Value::UInt(5) && alpha.get(i) != Some(&Value::UInt(5))));
if blank {
r.warn("placed images whose pixels are only in an original the reader can't decode, such as HEIC photos (left out)");
return Ok(None);
}
}
if step > 1 {
// Its stored tiles must fit what is left of the import's extraction budget.
let stored: usize = (1..=channels).map(|c| tiles_in(s, bitmap, c, bps, (x0, y0, w, h)).len()).sum();
if stored.saturating_mul(TILE_BYTES) > r.archive.remaining().max_total {
r.warn("pixel layers too large to read (left out)");
return Ok(None);
}
r.warn("pixel layers larger than 64 megapixels (read at a lower resolution)");
}
let mut cache = CachedPixels { tiles: HashMap::new(), source };
for c in 1..=channels {
planes.push(plane(r, bitmap, c, bps, (x0, y0, w, h), &mut cache)?);
planes.push(plane(r, bitmap, c, bps, (x0, y0, w, h), step, &mut cache)?);
}
let (w, h) = (w.div_ceil(step), h.div_ceil(step));
let n = (w as usize) * (h as usize);
let k = f64::from(step);
let transform = Affine([k, 0.0, 0.0, k, f64::from(x0), f64::from(y0)]).then(world);
if channels == 5 {
// Ink amounts and alpha as stored (8 bits), interleaved.
let mut cmyka = vec![0u8; n * 5];
for (i, px) in cmyka.as_chunks_mut::<5>().0.iter_mut().enumerate() {
for (c, v) in px.iter_mut().enumerate() {
*v = planes.get(c).and_then(|p| p.get(i)).copied().unwrap_or(0);
}
}
return Ok(Some(Image { width: w, height: h, pixels: Pixels::Cmyka8(cmyka), transform }));
}
let mut rgba = vec![0u8; n * 4];
let sample = |p: &Vec<u8>, i: usize| -> f64 {
if bps == 2 {
@@ -167,17 +258,9 @@ fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affi
};
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])
};
let (r2, g, b, a) = if channels == 1 { (v[0], v[0], v[0], 1.0) } 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 }))
}
@@ -229,28 +312,57 @@ 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.
/// Channel `c`'s tile grid: (tiles across, tiles down, each tile's state).
fn grid(s: &stream::Stream, bitmap: ObjId, c: usize, bps: usize) -> (usize, usize, Vec<u8>) {
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.saturating_mul(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(),
};
(tw, th, states)
}
/// The stored tiles (state 4) of channel `c` that overlap the crop, by their index in `Idx`.
fn tiles_in(s: &stream::Stream, bitmap: ObjId, c: usize, bps: usize, (x0, y0, w, h): (u32, u32, u32, u32)) -> Vec<usize> {
let (tw, th, states) = grid(s, bitmap, c, bps);
let (bx0, bx1) = (x0 as usize * bps, (x0 + w) as usize * bps);
let (py0, py1) = (y0 as usize, (y0 + h) as usize);
let mut out = Vec::new();
let mut next = 0usize;
for (t, state) in states.iter().enumerate().take(tw.saturating_mul(th)) {
if *state != 4 {
continue;
}
let (ox, oy) = ((t % tw.max(1)) * TILE, (t / tw.max(1)) * TILE);
if !(ox >= bx1 || ox + TILE <= bx0 || oy >= py1 || oy + TILE <= py0) {
out.push(next);
}
next += 1;
}
out
}
/// One channel of level 0, cropped to (x0, y0, w, h) pixels and taking every `step`th pixel of
/// every `step`th row; `bps` bytes per sample.
fn plane(
r: &mut Reader,
bitmap: ObjId,
c: usize,
bps: usize,
(x0, y0, w, h): (u32, u32, u32, u32),
step: u32,
cache: &mut CachedPixels,
) -> Result<Vec<u8>, Error> {
let CachedPixels { tiles: cache, source } = cache;
let s = r.s;
let width = s.int(bitmap, b"BmpW").and_then(|v| usize::try_from(v).ok()).unwrap_or(0);
let height = s.int(bitmap, b"BmpH").and_then(|v| usize::try_from(v).ok()).unwrap_or(0);
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 (tw, th, states) = grid(s, bitmap, c, bps);
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 at = Crop { bps, x0: x0 as usize, y0: y0 as usize, w: w as usize, h: h as usize, step: step.max(1) as usize };
let row = at.w.div_ceil(at.step) * bps;
let mut out = vec![0u8; row * at.h.div_ceil(at.step)];
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;
@@ -267,7 +379,7 @@ fn plane(
continue;
}
let name = s.entry(blck, b"Data").ok_or(Error::Malformed("pixel tile without data"))?.to_string();
if !cache.contains_key(&name) {
let read = |r: &mut Reader| -> Result<Vec<u8>, Error> {
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") {
@@ -276,10 +388,20 @@ fn plane(
if data.len() != TILE_BYTES {
return Err(Error::Malformed("pixel tile size"));
}
Ok(data)
};
// A layer read at a lower resolution is too large to keep its tiles around.
if at.step > 1 {
let tile = read(r)?;
copy(&mut out, &at, (ox, oy), |x, y| tile.get(y * TILE + x).copied().unwrap_or(0));
continue;
}
if !cache.contains_key(&name) {
let data = read(r)?;
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));
copy(&mut out, &at, (ox, oy), |x, y| tile.get(y * TILE + x).copied().unwrap_or(0));
continue;
}
3 => {
@@ -289,9 +411,7 @@ fn plane(
5 => {
if let Some((source_width, pixels)) = source.as_ref() {
let (ox, oy) = (tx * TILE, ty * TILE);
copy(&mut out, row, (bx0, py0, bx1, py1), (ox, oy), |x, y| {
pixels.get(((oy + y) * source_width + ox + x) * 4 + c - 1).copied().unwrap_or(0)
});
copy(&mut out, &at, (ox, oy), |x, y| pixels.get(((oy + y) * source_width + ox + x) * 4 + c - 1).copied().unwrap_or(0));
continue;
}
r.warn("pixel layers drawn from an embedded image (left empty)");
@@ -300,17 +420,38 @@ fn plane(
_ => 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);
copy(&mut out, &at, (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) {
/// Where a plane's pixels come from: the crop (pixels) and the sampling step.
struct Crop {
bps: usize,
x0: usize,
y0: usize,
w: usize,
h: usize,
step: usize,
}
/// Copy the overlap of a tile at byte column `ox`, row `oy` into the cropped plane, keeping every
/// `step`th pixel of every `step`th row.
fn copy(out: &mut [u8], c: &Crop, (ox, oy): (usize, usize), at: impl Fn(usize, usize) -> u8) {
let row = c.w.div_ceil(c.step) * c.bps;
let (bx0, bx1) = (c.x0 * c.bps, (c.x0 + c.w) * c.bps);
for y in oy.max(c.y0)..(oy + TILE).min(c.y0 + c.h) {
if !(y - c.y0).is_multiple_of(c.step) {
continue;
}
let out_row = (y - c.y0) / c.step * row;
for x in ox.max(bx0)..(ox + TILE).min(bx1) {
if let Some(b) = out.get_mut((y - py0) * row + (x - bx0)) {
let px = x / c.bps - c.x0;
if !px.is_multiple_of(c.step) {
continue;
}
if let Some(b) = out.get_mut(out_row + px / c.step * c.bps + x % c.bps) {
*b = at(x - ox, y - oy);
}
}
@@ -323,6 +464,15 @@ mod tests {
use crate::model::Kind;
use crate::synth::{self, F, Method, tag};
#[test]
fn heif_originals_are_recognised_by_their_brand() {
assert!(is_heif(b"\0\0\0\x18ftypheic\0\0\0\0"));
assert!(is_heif(b"\0\0\0\x1cftypavif"));
assert!(!is_heif(b"\0\0\0\x18ftypisom"), "a video is not a photo");
assert!(!is_heif(&png()));
assert!(!is_heif(b"ftyp"));
}
fn png() -> Vec<u8> {
let pixels = image::RgbaImage::from_fn(300, 2, |x, y| image::Rgba([(x % 251) as u8, (y * 31) as u8, 77, 255]));
let mut bytes = std::io::Cursor::new(Vec::new());
@@ -333,18 +483,29 @@ mod tests {
/// An original synthetic mixed source/cache bitmap. It exercises reader recovery,
/// not a claim that Affinity itself accepts the synthetic container.
fn fixture(source: &[u8], origin: i32) -> Vec<u8> {
fixture_in(source, origin, false)
}
/// `cmyk`: a CMYK bitmap whose colour tiles are all in the source and whose alpha is stored.
fn fixture_in(source: &[u8], origin: i32, cmyk: bool) -> Vec<u8> {
let channels = if cmyk { 5 } else { 4 };
let mut bitmap = vec![
(tag(b"Frmt"), F::Enum(0, 0)),
(tag(b"Frmt"), F::Enum(if cmyk { 4 } else { 0 }, 0)),
(tag(b"BmpW"), F::I32(300)),
(tag(b"BmpH"), F::I32(2)),
(tag(b"LInf"), F::I32(origin)),
(tag(b"TInf"), F::I32(0)),
(tag(b"Bckg"), F::Entry("c/1".into())),
];
for c in 1..=4 {
for c in 1..=channels {
let states = match (cmyk, c) {
(false, _) => vec![5, 4],
(true, 5) => vec![4, 4],
(true, _) => vec![5, 5],
};
bitmap.push((tag(&super::tag(b"TWi", c)), F::I32(2)));
bitmap.push((tag(&super::tag(b"THi", c)), F::I32(1)));
bitmap.push((tag(&super::tag(b"Sta", c)), F::U8s(vec![5, 4])));
bitmap.push((tag(&super::tag(b"Sta", c)), F::U8s(states)));
bitmap.push((
tag(&super::tag(b"Idx", c)),
F::Shared(vec![F::Def(c as u32, vec![tag(b"Blck")], vec![(tag(b"Data"), F::Entry(format!("d/{c}")))])]),
@@ -369,18 +530,44 @@ mod tests {
blob.extend((source.len() as u32).to_le_bytes());
blob.extend(source);
let block = synth::stream(&[(tag(b"Data"), F::Raw(blob))]);
let tiles: Vec<_> = [200u8, 10, 30, 255].into_iter().map(|v| vec![v; TILE_BYTES]).collect();
synth::container(
&[
("doc.dat", &doc, Method::Zlib),
("c/1", &block, Method::Zlib),
("d/1", &tiles[0], Method::Zlib),
("d/2", &tiles[1], Method::Zlib),
("d/3", &tiles[2], Method::Zlib),
("d/4", &tiles[3], Method::Zlib),
],
None,
)
let tiles: Vec<_> = [200u8, 10, 30, 255, 255].into_iter().map(|v| vec![v; TILE_BYTES]).collect();
let names: Vec<String> = (1..=channels).map(|c| format!("d/{c}")).collect();
let mut entries = vec![("doc.dat", doc.as_slice(), Method::Zlib), ("c/1", block.as_slice(), Method::Zlib)];
for (name, tile) in names.iter().zip(&tiles) {
entries.push((name.as_str(), tile.as_slice(), Method::Zlib));
}
synth::container(&entries, None)
}
#[test]
fn a_cmyk_layer_whose_colours_are_only_in_an_unreadable_original_is_left_out_not_white() {
let doc = crate::read(&fixture_in(b"\0\0\0\x18ftypheic not decodable", 0, true), crate::Limits::default()).unwrap();
assert!(doc.warnings.iter().any(|w| w.starts_with("placed images whose pixels are only in an original")), "{:?}", doc.warnings);
assert!(matches!(doc.spreads[0].nodes[0].kind, Kind::Unsupported), "{:?}", doc.spreads[0].nodes[0].kind);
}
#[test]
fn layers_over_the_pixel_budget_are_read_at_a_lower_resolution() {
let bytes = fixture(&png(), 0);
let mut archive = crate::Archive::open(&bytes, crate::Limits::default()).unwrap();
let doc = archive.read("doc.dat").unwrap();
let s = stream::parse(&doc).unwrap();
let bitmap = s.objects.iter().position(|o| o.class == Tag::of(b"DyBm")).unwrap();
let mut r = Reader::new(&s, &mut archive, Affine::IDENTITY, 0);
// 20 × 2 pixels over a budget of 10: every other pixel of every other row.
let image = decode_within(&mut r, bitmap, (250, 0, 270, 2), Affine::IDENTITY, 10).unwrap().unwrap();
assert_eq!((image.width, image.height), (10, 1));
assert_eq!(image.transform, Affine([2.0, 0.0, 0.0, 2.0, 250.0, 0.0]), "each pixel covers two");
let Pixels::Rgba8(pixels) = &image.pixels else { panic!() };
for x in 0..10 {
let sx = 2 * x;
let expected = if sx + 250 < 256 { [((sx + 250) % 251) as u8, 0, 77, 255] } else { [200, 10, 30, 255] };
assert_eq!(&pixels[x * 4..x * 4 + 4], &expected, "pixel {x}");
}
assert!(r.warnings.contains_key("pixel layers larger than 64 megapixels (read at a lower resolution)"));
// Within budget, nothing changes.
let image = decode_within(&mut r, bitmap, (250, 0, 270, 2), Affine::IDENTITY, 40).unwrap().unwrap();
assert_eq!((image.width, image.height), (20, 2));
}
#[test]
+282 -98
View File
@@ -2,7 +2,7 @@
//! 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::model::{Affine, Align, Paragraph, Point, Reader, Table, TableCell, Text, TextRun, VAlign, rect};
use crate::paint::{self, Color, Paint};
use crate::stream::{ObjId, Value};
@@ -11,92 +11,16 @@ const MAX_CHARS: usize = 1 << 20;
/// Shared character attributes must not amplify into unbounded font/feature/gradient data.
const MAX_ATTRIBUTE_BYTES: usize = 16 << 20;
/// Ends a table cell in a table's story (Affinity's break glyph of kind 4).
pub const CELL_BREAK: char = '\u{1F}';
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;
let mut attribute_budget = Some(MAX_ATTRIBUTE_BYTES);
for block in s.objs(story, b"Blok") {
let Some(glyphs) = s.obj(block, b"Glyp") else {
r.warn("a text block without readable characters (left out)");
continue;
};
// Bound collection itself, including placeholders, before allocating the character buffer.
let budget = MAX_CHARS - total;
let mut chars: Vec<char> = match s.str(glyphs, b"Utf8") {
Some(t) => t.chars().take(budget + 1).collect(),
None => {
// Inline objects (page numbers, anchors) each take one index before the segment text.
let segments = s.objs(glyphs, b"Mixd");
if segments.is_empty() {
r.warn("a text block without readable characters (left out)");
continue;
}
let mut v = Vec::new();
for seg in segments {
let inline = s.objs(seg, b"Glys").len();
v.extend(std::iter::repeat_n('\u{FFFC}', inline.min(budget + 1 - v.len())));
placeholders |= inline > 0;
v.extend(s.str(seg, b"Utf8").unwrap_or_default().chars().take(budget + 1 - v.len()));
if v.len() > budget {
break;
}
}
v
}
};
let truncated = chars.len() > budget;
if truncated {
chars.truncate(budget);
r.warn("text longer than a million characters (truncated)");
}
total += chars.len();
let mut start = 0usize;
for run in s.obj(block, b"GAtt").map(|g| s.objs(g, b"Runs")).unwrap_or_default() {
let Some(index) = s.int(run, b"Indx").and_then(|v| usize::try_from(v).ok()) else {
r.warn("invalid text attribute range (bounded; unassigned characters use defaults)");
continue;
};
let end = index.min(chars.len());
if index > chars.len() || end < start {
r.warn("invalid text attribute range (bounded; unassigned characters use defaults)");
}
if end <= start {
continue;
}
let text = characters(&chars, start, end);
start = end;
push_run(r, &mut runs, s.obj(run, b"Item"), text, world, &mut attribute_budget);
}
if start < chars.len() {
let text = characters(&chars, start, chars.len());
push_run(r, &mut runs, None, text, world, &mut attribute_budget);
}
for paragraph in s.obj(block, b"PAtt").map(|p| s.objs(p, b"Runs")).unwrap_or_default() {
if let Some(attrs) = s.obj(paragraph, b"Item") {
let paragraph = paragraph_align(r, attrs);
match align {
None => align = Some(paragraph),
Some(first) if first != paragraph => r.warn("mixed paragraph alignments (the first alignment is used)"),
_ => {}
}
}
}
if truncated {
break;
}
}
if placeholders {
r.warn("text fields such as page numbers (left out of the text)");
}
let align = align.unwrap_or(Align::Left);
let (runs, align) = story_runs(r, story, world);
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,
@@ -111,19 +35,231 @@ pub(crate) fn read(r: &mut Reader, id: ObjId, world: Affine) -> Option<Text> {
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 })
let text_transform = if artistic { world } else { text_scale(r, id).then(r.type_base) };
Some(Text {
runs,
align,
anchor: Point { x: anchor_x, y: first_baseline },
frame: if artistic { None } else { Some(bounds) },
transform: world,
text_transform,
})
}
/// Rows and columns a table may have.
const MAX_TABLE_LINES: usize = 4096;
/// A table node (`TxtT`): the grid, the cells' text and the lines on the cell edges.
pub(crate) fn table(r: &mut Reader, id: ObjId, world: Affine) -> Option<Table> {
let s = r.s;
let story = s.obj(id, b"StSt")?;
let tabl = s.obj(id, b"TxtH").and_then(|h| s.obj(h, b"Tabl"))?;
let bounds = |pos: Option<ObjId>| -> Option<Vec<f64>> {
let Some(Value::Array(a)) = pos.and_then(|p| s.field(p, b"Posn")) else { return None };
let v: Vec<f64> = a.iter().take(MAX_TABLE_LINES + 2).map(float).collect::<Option<_>>()?;
(v.len() >= 2 && v.len() <= MAX_TABLE_LINES + 1 && v.windows(2).all(|w| w[1] >= w[0])).then_some(v)
};
let columns = bounds(s.obj(tabl, b"CPos"))?;
let rows = bounds(s.obj(tabl, b"RPos"))?;
let (ncols, nrows) = (columns.len() - 1, rows.len() - 1);
let count = ncols.checked_mul(nrows)?;
let cell_objs = s.obj(tabl, b"Cell").map(|c| s.objs(c, b"Cell")).unwrap_or_default();
if cell_objs.len() != count {
r.warn("tables whose cells don't match their grid (cell formatting left out)");
}
let blank = || TableCell { runs: Vec::new(), inset: [0.0; 4], valign: VAlign::Top, merged_left: false };
let mut cells: Vec<TableCell> = (0..count).map(|_| blank()).collect();
for (i, (cell, obj)) in cells.iter_mut().zip(&cell_objs).enumerate() {
if let Some(inset) = s.floats::<4>(*obj, b"Inse").filter(|v| v.iter().all(|x| x.is_finite() && *x >= 0.0)) {
cell.inset = inset;
}
cell.valign = match s.enumeration(*obj, b"AliY").map(|(i, _)| i) {
Some(1) => VAlign::Center,
Some(2) => VAlign::Bottom,
_ => VAlign::Top,
};
// A cell merged into the one on its left; the first column has nothing to its left.
cell.merged_left = i % ncols != 0 && s.bool(*obj, b"BrLf") == Some(true);
}
// The story holds the text of every cell that isn't merged away, row by row, each ended by a
// cell break.
let owner_list: Vec<usize> = (0..count).filter(|i| cells.get(*i).is_some_and(|c| !c.merged_left)).collect();
let mut owners = owner_list.into_iter();
let mut target = owners.next();
let mut extra = false;
let (runs, _) = story_runs(r, story, world);
for run in runs {
let mut parts = run.text.split(CELL_BREAK).peekable();
while let Some(part) = parts.next() {
if !part.is_empty() {
match target.and_then(|i| cells.get_mut(i)) {
Some(cell) => cell.runs.push(TextRun { text: part.to_string(), ..run.clone() }),
None => extra = true,
}
}
if parts.peek().is_some() {
target = owners.next();
}
}
}
if extra {
r.warn("table text beyond its last cell (left out)");
}
let mut edges = |tag: &[u8; 4], expected: usize| -> Vec<Option<crate::paint::Stroke>> {
let objs = s.obj(tabl, tag).map(|e| s.objs(e, b"Edge")).unwrap_or_default();
if objs.len() != expected {
r.warn("tables whose lines don't match their grid (lines left out)");
return vec![None; expected];
}
objs.into_iter()
.map(|e| match (s.obj(e, b"Fill"), s.obj(e, b"Line")) {
(Some(pen), Some(line)) => paint::stroke(r, pen, line, world, false),
_ => None,
})
.collect()
};
let vertical = edges(b"CEdg", (ncols + 1) * nrows);
let horizontal = edges(b"REdg", ncols * (nrows + 1));
if let Some(format) = s.obj(tabl, b"TFmt") {
let filled =
s.objs(format, b"Clls").into_iter().filter_map(|c| s.obj(c, b"Fill")).any(|f| s.obj(f, b"FDeF").is_some_and(|d| !s.is(d, b"FilN")));
if filled {
r.warn("table cell fills (left out)");
}
}
let text_transform = text_scale(r, id).then(r.type_base);
Some(Table { columns, rows, cells, vertical, horizontal, transform: world, text_transform })
}
/// A story's character runs, each with its paragraph's format, and the first paragraph's
/// alignment. Table cells end with [`CELL_BREAK`].
pub(crate) fn story_runs(r: &mut Reader, story: ObjId, world: Affine) -> (Vec<TextRun>, Align) {
let s = r.s;
let mut runs = Vec::new();
let mut first_align = None;
let mut total = 0usize;
let mut placeholders = false;
let mut attribute_budget = Some(MAX_ATTRIBUTE_BYTES);
for block in s.objs(story, b"Blok") {
let Some(glyphs) = s.obj(block, b"Glyp") else {
r.warn("a text block without readable characters (left out)");
continue;
};
// Bound collection itself, including placeholders, before allocating the character buffer.
let budget = MAX_CHARS - total;
let mut chars: Vec<char> = match s.str(glyphs, b"Utf8") {
Some(t) => t.chars().take(budget + 1).collect(),
None => {
// Inline objects (page numbers, anchors, cell ends) each take one index before
// the segment text.
let segments = s.objs(glyphs, b"Mixd");
if segments.is_empty() {
r.warn("a text block without readable characters (left out)");
continue;
}
let mut v = Vec::new();
for seg in segments {
for inline in s.objs(seg, b"Glys") {
if v.len() > budget {
break;
}
if s.is(inline, b"BrGl") && s.enumeration(inline, b"HdBk").is_some_and(|(kind, _)| kind == 4) {
v.push(CELL_BREAK);
} else {
v.push('\u{FFFC}');
placeholders = true;
}
}
v.extend(s.str(seg, b"Utf8").unwrap_or_default().chars().take((budget + 1).saturating_sub(v.len())));
if v.len() > budget {
break;
}
}
v
}
};
let truncated = chars.len() > budget;
if truncated {
chars.truncate(budget);
r.warn("text longer than a million characters (truncated)");
}
total += chars.len();
// Paragraph formats by end index; a run without an index covers the rest of the block.
let mut paragraphs: Vec<(usize, Paragraph)> = Vec::new();
let mut open: Option<Paragraph> = None;
for run in s.obj(block, b"PAtt").map(|p| s.objs(p, b"Runs")).unwrap_or_default() {
let Some(attrs) = s.obj(run, b"Item") else { continue };
let a = paragraph(r, attrs);
first_align.get_or_insert(a.align);
match s.int(run, b"Indx").and_then(|v| usize::try_from(v).ok()) {
Some(end) => paragraphs.push((end, a)),
None => match open {
None => open = Some(a),
Some(first) if first != a => r.warn("mixed paragraph formats in one block (the first is used)"),
_ => {}
},
}
}
let align_at = |i: usize| paragraphs.iter().find(|(end, _)| i < *end).map(|(_, a)| *a).or(open).unwrap_or_default();
let mut cuts: Vec<usize> = paragraphs.iter().map(|(end, _)| *end).filter(|e| *e < chars.len()).collect();
cuts.sort_unstable();
cuts.dedup();
let push = |r: &mut Reader, runs: &mut Vec<TextRun>, attrs: Option<ObjId>, from: usize, to: usize, budget: &mut Option<usize>| {
// Split at paragraph ends so every run has one paragraph format.
let mut a = from;
for &c in cuts.iter().filter(|c| **c > from && **c < to).chain(std::iter::once(&to)) {
push_run(r, runs, attrs, characters(&chars, a, c), world, budget, align_at(a));
a = c;
}
};
let mut start = 0usize;
for run in s.obj(block, b"GAtt").map(|g| s.objs(g, b"Runs")).unwrap_or_default() {
let Some(index) = s.int(run, b"Indx").and_then(|v| usize::try_from(v).ok()) else {
r.warn("invalid text attribute range (bounded; unassigned characters use defaults)");
continue;
};
let end = index.min(chars.len());
if index > chars.len() || end < start {
r.warn("invalid text attribute range (bounded; unassigned characters use defaults)");
}
if end <= start {
continue;
}
push(r, &mut runs, s.obj(run, b"Item"), start, end, &mut attribute_budget);
start = end;
}
if start < chars.len() {
push(r, &mut runs, None, start, chars.len(), &mut attribute_budget);
}
if truncated {
break;
}
}
if placeholders {
r.warn("text fields such as page numbers (left out of the text)");
}
(runs, first_align.unwrap_or(Align::Left))
}
fn characters(chars: &[char], start: usize, end: usize) -> String {
chars.get(start..end).unwrap_or_default().iter().filter(|c| **c != '\0' && **c != '\u{FFFC}').collect()
}
fn push_run(r: &mut Reader, runs: &mut Vec<TextRun>, attrs: Option<ObjId>, text: String, world: Affine, attribute_budget: &mut Option<usize>) {
fn push_run(
r: &mut Reader,
runs: &mut Vec<TextRun>,
attrs: Option<ObjId>,
text: String,
world: Affine,
attribute_budget: &mut Option<usize>,
paragraph: Paragraph,
) {
if text.is_empty() {
return;
}
let run = match attrs {
Some(attrs) if attribute_budget.is_some() => run_attrs(r, attrs, text, world, attribute_budget),
Some(attrs) if attribute_budget.is_some() => TextRun { paragraph, ..run_attrs(r, attrs, text, world, attribute_budget) },
_ => {
// This is a recovery policy, not an assertion about unspecified Affinity attributes.
if attrs.is_none() {
@@ -137,10 +273,15 @@ fn push_run(r: &mut Reader, runs: &mut Vec<TextRun>, attrs: Option<ObjId>, text:
italic: false,
features: Vec::new(),
all_caps: false,
underline: false,
strikethrough: false,
size: 12.0 * r.dpi / 72.0,
tracking: 0.0,
h_scale: 1.0,
leading: None,
fill: Paint::Solid(Color::Gray { v: 0.0, a: 1.0 }),
stroke: None,
paragraph,
}
}
};
@@ -195,13 +336,22 @@ fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine, attribut
fill = Paint::Solid(Color::Gray { v: 0.0, a: 1.0 });
}
}
if attribute_budget.is_some() && objects.get(1).copied().flatten().and_then(|f| paint::descriptor(r, f, world)).is_some_and(|p| p != Paint::None)
{
r.warn("outlined (stroked) text");
}
if slot(doubles, 10, float).is_some_and(|h| (h - 1.0).abs() > 1e-3) {
r.warn("horizontally scaled text");
}
// Outlined characters: `Objs` 1 is the outline's paint, 2 its line style (as for shapes).
let stroke = match (attribute_budget.is_some(), objects.get(1).copied().flatten(), objects.get(2).copied().flatten()) {
(true, Some(pen), Some(style)) => paint::stroke(r, pen, style, Affine::IDENTITY, false),
_ => None,
};
// Underline (slot 0) and strikethrough (slot 1): 1 is a single line.
let mut decoration = |i: usize| match slot(ints, i, int) {
Some(1) => true,
Some(0) | None => false,
Some(_) => {
r.warn("text decorations of an unknown kind (left out)");
false
}
};
let (underline, strikethrough) = (decoration(0), decoration(1));
let h_scale = slot(doubles, 10, float).filter(|h| *h > 0.0 && *h < 100.0).unwrap_or(1.0);
// A resolved font is populated on the emoji run of the public Affinity 3 text-runs file
// (Courier New requested, Segoe UI Emoji stored in RFnt). Empty RFnt records are common.
// Source: https://github.com/SethRobinson/Patchy/tree/de84eab550758b30fa062e479f5778cce7693b73/test-fixtures/af
@@ -238,10 +388,15 @@ fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine, attribut
italic: font.and_then(|f| s.bool(f, b"Ital")).unwrap_or(false),
features,
all_caps,
underline,
strikethrough,
size,
tracking,
h_scale,
leading,
fill,
stroke,
paragraph: Paragraph::default(),
}
}
@@ -303,13 +458,18 @@ fn font_features(r: &mut Reader, attrs: Option<ObjId>, attribute_budget: &mut Op
(features, all_caps)
}
fn paragraph_align(r: &mut Reader, p: ObjId) -> Align {
// Nonzero offsets in these slots are present in the public text-indent and text-para-spacing
// fixtures. Their layout is not represented by this importer; retain text and report the gap.
if (1..=6).any(|i| slot(r.s.field(p, b"Doub"), i, float).is_some_and(|v| v.abs() > 1e-9)) {
r.warn("paragraph indents or spacing (imported with defaults)");
}
match slot(r.s.field(p, b"Ints"), 0, int) {
/// A paragraph's alignment, indents, spacing and leading. The `Doub` slots: 0 the automatic
/// leading as a multiple of the font's line height (1 in every document checked), 1 fixed leading
/// (when `Ints` slot 1 is 2; in every paragraph checked the two go together), 2 indent of the
/// lines after the first, 3 right indent, 4 indent of the first line, 5 space before, 6 space
/// after. Slots 2, 4, 5 and 6 were identified on the public MIT text-indent and text-para-spacing
/// fixtures (Patchy) by measuring their thumbnails, slot 1 by measuring the baseline pitch in the
/// thumbnail of a document whose owner made it earlier.
fn paragraph(r: &mut Reader, p: ObjId) -> Paragraph {
let doubles = r.s.field(p, b"Doub");
let ints = r.s.field(p, b"Ints");
let at = |i: usize| slot(doubles, i, float).filter(|v| v.abs() < 1e6).unwrap_or(0.0);
let align = match slot(ints, 0, int) {
Some(1) => Align::Center,
Some(2) => Align::Right,
Some(3) => Align::Justify,
@@ -318,5 +478,29 @@ fn paragraph_align(r: &mut Reader, p: ObjId) -> Align {
r.warn("an unknown paragraph alignment (imported as left)");
Align::Left
}
};
let leading = match slot(ints, 1, int) {
Some(2) => Some(at(1)).filter(|l| *l > 0.0),
Some(0) | None => None,
Some(_) => {
r.warn("paragraph leading of an unknown kind (imported as automatic)");
None
}
};
let auto_leading = slot(doubles, 0, float).filter(|v| *v > 0.0 && *v < 100.0).unwrap_or(1.0);
Paragraph { align, left: at(2), right: at(3), first: at(4), space_before: at(5).max(0.0), space_after: at(6).max(0.0), leading, auto_leading }
}
/// The scale a text frame or table gives its type (`FTxS`, stored like `Xfrm`): none unless set.
/// Neither the frame's own transform nor its groups' scale its type (checked against the
/// thumbnails of tables and frames inside scaled groups).
fn text_scale(r: &mut Reader, id: ObjId) -> Affine {
match r.s.floats::<6>(id, b"FTxS").map(Affine::from_xfrm) {
Some(m) if m.is_finite() && m.scale() > 1e-9 => Affine([m.0[0], m.0[1], m.0[2], m.0[3], 0.0, 0.0]),
Some(_) => {
r.warn("text frames with an invalid text scale (imported unscaled)");
Affine::IDENTITY
}
None => Affine::IDENTITY,
}
}
+7 -4
View File
@@ -103,13 +103,16 @@ fn rotated_scaled_pixels_keep_the_full_affine_transform() {
}
#[test]
fn cmyk_and_lab_losses_are_reported() {
fn cmyk_pixels_stay_cmyk_and_lab_losses_are_reported() {
// CMYK layers keep their inks for the importer to convert through a colour profile.
let Some((_, cmyk, images)) = read_fixture("patchy-cmyk.af") else { return };
assert_eq!(images.len(), 1);
assert_eq!((images[0].width, images[0].height), (64, 48));
let Pixels::Rgba8(pixels) = &images[0].pixels else { panic!() };
assert_eq!(crc32fast::hash(pixels), 0x5872bb76);
assert_eq!(cmyk.warnings, ["CMYK pixel layers (converted to RGB without the document's colour profile)"]);
let Pixels::Cmyka8(pixels) = &images[0].pixels else { panic!() };
assert_eq!(pixels.len(), 64 * 48 * 5);
assert_eq!(crc32fast::hash(pixels), 0x56b1f659);
assert!(cmyk.warnings.is_empty(), "{:?}", cmyk.warnings);
assert_eq!(cmyk.cmyk_profile, None, "this file embeds no profile");
let (_, lab, images) = read_fixture("patchy-lab.af").unwrap();
assert!(images.is_empty());
assert_eq!(lab.warnings, ["pixel layers in grey, Lab or 32-bit formats"]);
@@ -1,33 +1,33 @@
affinity3-mexican-guy.af dpi=300;spreads=1;model_crc32=df3c417b;counts={"depth": 8, "fills": 169, "gradients": 0, "groups": 23, "nodes": 283, "opacity": 1, "segments": 1491, "shapes": 260, "strokes": 114, "subpaths": 264};boards=[];fonts={};warnings=["brush and pressure strokes (imported as plain strokes) (114×)", "layer effects (shadows, glows, outlines, bevels…) (156×)"]
affinity3-mexican-man.af dpi=300;spreads=1;model_crc32=d0387de4;counts={"depth": 7, "fills": 277, "gradients": 2, "groups": 29, "nodes": 903, "segments": 3225, "shapes": 874, "strokes": 559, "subpaths": 881};boards=[];fonts={};warnings=["brush and pressure strokes (imported as plain strokes) (545×)", "layer effects (shadows, glows, outlines, bevels…) (245×)"]
affinity3-mexican-woman.af dpi=300;spreads=1;model_crc32=b966f693;counts={"depth": 9, "fills": 527, "gradients": 0, "groups": 28, "nodes": 595, "segments": 3562, "shapes": 567, "strokes": 60, "subpaths": 570};boards=[];fonts={};warnings=["brush and pressure strokes (imported as plain strokes) (54×)", "layer effects (shadows, glows, outlines, bevels…) (278×)"]
affinity3-playing-cards.af dpi=300;spreads=1;model_crc32=ac448123;counts={"depth": 5, "fills": 363, "gradients": 0, "groups": 758, "nodes": 1350, "segments": 4864, "shapes": 592, "strokes": 288, "subpaths": 656};boards=[];fonts={};warnings=["brush and pressure strokes (imported as plain strokes)", "layer effects (shadows, glows, outlines, bevels…) (339×)"]
patchy-adjust-curves.af dpi=72;spreads=1;model_crc32=10a37502;counts={"depth": 1, "images": 1, "nodes": 2, "unsupported": 1};boards=[];fonts={};warnings=["adjustment layers"]
patchy-adjust-hsl.af dpi=72;spreads=1;model_crc32=c65409b4;counts={"depth": 1, "images": 1, "nodes": 2, "unsupported": 1};boards=[];fonts={};warnings=["adjustment layers"]
patchy-artboards.af dpi=72;spreads=1;model_crc32=f1ae5644;counts={"artboards": 2, "depth": 2, "fills": 2, "gradients": 0, "nodes": 4, "segments": 7, "shapes": 2, "strokes": 0, "subpaths": 2};boards=["board-a:0.0000,0.0000,80.0000,60.0000", "board-b:100.0000,10.0000,160.0000,70.0000"];fonts={};warnings=[]
patchy-blend-affinity.af dpi=72;spreads=1;model_crc32=f6691ff9;counts={"depth": 2, "fills": 8, "gradients": 0, "images": 1, "layers": 1, "nodes": 10, "segments": 24, "shapes": 8, "strokes": 0, "subpaths": 8};boards=[];fonts={};warnings=["an unknown blend mode (imported as Normal) (2×)"]
patchy-cmyk.af dpi=72;spreads=1;model_crc32=5145b5a1;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=["CMYK pixel layers (converted to RGB without the document's colour profile)"]
patchy-dpi300.af dpi=300;spreads=1;model_crc32=0c23e7c8;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
patchy-embedded-jpeg.af dpi=72;spreads=1;model_crc32=090c674b;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
patchy-fx-blur.af dpi=72;spreads=1;model_crc32=3f1b8b44;counts={"depth": 1, "images": 2, "nodes": 2};boards=[];fonts={};warnings=["layer effects (shadows, glows, outlines, bevels…) (2×)"]
patchy-fx-gradient.af dpi=72;spreads=1;model_crc32=e868c992;counts={"depth": 1, "images": 3, "nodes": 3};boards=[];fonts={};warnings=["layer effects (shadows, glows, outlines, bevels…) (3×)"]
patchy-fx.af dpi=72;spreads=1;model_crc32=5a1c34ac;counts={"depth": 1, "images": 5, "nodes": 5};boards=[];fonts={};warnings=["layer effects (shadows, glows, outlines, bevels…) (5×)"]
patchy-group.af dpi=72;spreads=1;model_crc32=c217ab38;counts={"depth": 2, "images": 3, "layers": 1, "nodes": 4};boards=[];fonts={};warnings=[]
patchy-incremental-chain.af dpi=72;spreads=1;model_crc32=63ad778e;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=["layer effects (shadows, glows, outlines, bevels…)"]
patchy-lab.af dpi=72;spreads=1;model_crc32=01e8a926;counts={"depth": 1, "nodes": 1, "unsupported": 1};boards=[];fonts={};warnings=["pixel layers in grey, Lab or 32-bit formats"]
patchy-lazy-placed.af dpi=72;spreads=1;model_crc32=9ce9937e;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
patchy-live-filter.af dpi=72;spreads=1;model_crc32=30c5f8ee;counts={"depth": 2, "fills": 2, "gradients": 0, "nodes": 4, "segments": 6, "shapes": 2, "strokes": 0, "subpaths": 2, "unsupported": 2};boards=[];fonts={};warnings=["adjustment layers", "unknown layers"]
patchy-rgba16.af dpi=72;spreads=1;model_crc32=1bb23571;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
patchy-rgba8.af dpi=72;spreads=1;model_crc32=1bb23571;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
patchy-shapes-2.af dpi=72;spreads=1;model_crc32=7828d3cd;counts={"depth": 1, "fills": 12, "gradients": 0, "nodes": 12, "segments": 62, "shapes": 12, "strokes": 0, "subpaths": 12};boards=[];fonts={};warnings=["cloud, heart, cog, callout, arrow, tear and other special shapes (imported as their bounding ellipse) (9×)"]
patchy-shapes.af dpi=72;spreads=1;model_crc32=7519f5fe;counts={"depth": 1, "fills": 4, "gradients": 0, "nodes": 4, "segments": 28, "shapes": 4, "strokes": 0, "subpaths": 4};boards=[];fonts={};warnings=[]
patchy-text-artistic.af dpi=72;spreads=1;model_crc32=b6137f6e;counts={"depth": 1, "nodes": 1, "text_chars": 5, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialMT:400:false"};warnings=[]
patchy-text-caps.af dpi=72;spreads=1;model_crc32=b5527e72;counts={"depth": 1, "nodes": 2, "text_chars": 21, "text_runs": 2, "texts": 2};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font) (2×)"]
patchy-text-frame.af dpi=72;spreads=1;model_crc32=7a2cd32d;counts={"depth": 1, "nodes": 1, "text_chars": 11, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialMT:400:false"};warnings=["paragraph indents or spacing (imported with defaults)"]
patchy-text-indent.af dpi=72;spreads=1;model_crc32=30ee834c;counts={"depth": 1, "nodes": 2, "text_chars": 104, "text_runs": 2, "texts": 2};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font) (2×)", "paragraph indents or spacing (imported with defaults) (2×)"]
patchy-text-para-spacing.af dpi=72;spreads=1;model_crc32=3d580ad2;counts={"depth": 1, "nodes": 1, "text_chars": 13, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)", "paragraph indents or spacing (imported with defaults)"]
patchy-text-rotated.af dpi=72;spreads=1;model_crc32=ad77772a;counts={"depth": 1, "nodes": 1, "text_chars": 7, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)"]
patchy-text-runs.af dpi=72;spreads=1;model_crc32=a8da3052;counts={"depth": 1, "nodes": 1, "text_chars": 7, "text_runs": 5, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false", "Courier New:CourierNewPSMT:400:false", "Segoe UI Emoji:SegoeUIEmoji:400:false", "Times New Roman:TimesNewRomanPSMT:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)", "paragraph indents or spacing (imported with defaults)", "text uses a stored fallback font (imported with that font)"]
patchy-transform.af dpi=72;spreads=1;model_crc32=f8807611;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
patchy-vector-mask.af dpi=72;spreads=1;model_crc32=1851fd90;counts={"depth": 1, "fills": 2, "gradients": 0, "nodes": 2, "segments": 7, "shapes": 2, "strokes": 0, "subpaths": 2, "vector_masks": 1};boards=[];fonts={};warnings=["unknown layers attached to other layers (imported without them)"]
patchy-vector.af dpi=72;spreads=1;model_crc32=b89a91d3;counts={"depth": 1, "fills": 3, "gradients": 0, "nodes": 4, "segments": 17, "shapes": 4, "strokes": 2, "subpaths": 5};boards=[];fonts={};warnings=[]
affinity3-mexican-guy.af dpi=300;spreads=1;model_crc32=d23a6900;counts={"depth": 8, "fills": 169, "gradients": 0, "groups": 23, "nodes": 283, "opacity": 1, "segments": 1491, "shapes": 260, "strokes": 114, "subpaths": 264};boards=[];fonts={};warnings=["blur effects (left out) (156×)", "brush and pressure strokes (imported as plain strokes) (114×)"]
affinity3-mexican-man.af dpi=300;spreads=1;model_crc32=b8cecb1c;counts={"depth": 7, "fills": 277, "gradients": 2, "groups": 29, "nodes": 903, "segments": 3225, "shapes": 874, "strokes": 540, "subpaths": 881};boards=[];fonts={};warnings=["blur effects (left out) (244×)", "brush and pressure strokes (imported as plain strokes) (534×)"]
affinity3-mexican-woman.af dpi=300;spreads=1;model_crc32=23d1e39c;counts={"depth": 9, "fills": 527, "gradients": 0, "groups": 28, "nodes": 595, "segments": 3562, "shapes": 567, "strokes": 60, "subpaths": 570};boards=[];fonts={};warnings=["blur effects (left out) (278×)", "brush and pressure strokes (imported as plain strokes) (54×)"]
affinity3-playing-cards.af dpi=300;spreads=1;model_crc32=cafb7572;counts={"depth": 5, "fills": 363, "gradients": 0, "groups": 758, "nodes": 1350, "segments": 4864, "shapes": 592, "strokes": 17, "subpaths": 656};boards=[];fonts={};warnings=["blur effects (left out) (309×)", "brush and pressure strokes (imported as plain strokes)"]
patchy-adjust-curves.af dpi=72;spreads=1;model_crc32=36ab962f;counts={"depth": 1, "images": 1, "nodes": 2, "unsupported": 1};boards=[];fonts={};warnings=["adjustment layers"]
patchy-adjust-hsl.af dpi=72;spreads=1;model_crc32=e2eac05e;counts={"depth": 1, "images": 1, "nodes": 2, "unsupported": 1};boards=[];fonts={};warnings=["adjustment layers"]
patchy-artboards.af dpi=72;spreads=1;model_crc32=0037a28e;counts={"artboards": 2, "depth": 2, "fills": 2, "gradients": 0, "nodes": 4, "segments": 7, "shapes": 2, "strokes": 0, "subpaths": 2};boards=["board-a:0.0000,0.0000,80.0000,60.0000", "board-b:100.0000,10.0000,160.0000,70.0000"];fonts={};warnings=[]
patchy-blend-affinity.af dpi=72;spreads=1;model_crc32=8497c917;counts={"depth": 2, "fills": 8, "gradients": 0, "images": 1, "layers": 1, "nodes": 10, "segments": 24, "shapes": 8, "strokes": 0, "subpaths": 8};boards=[];fonts={};warnings=["an unknown blend mode (imported as Normal) (2×)"]
patchy-cmyk.af dpi=72;spreads=1;model_crc32=56754ce9;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
patchy-dpi300.af dpi=300;spreads=1;model_crc32=2561f9d3;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
patchy-embedded-jpeg.af dpi=72;spreads=1;model_crc32=ab4b6fbf;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
patchy-fx-blur.af dpi=72;spreads=1;model_crc32=29b5083b;counts={"depth": 1, "images": 2, "nodes": 2};boards=[];fonts={};warnings=["blur effects (left out) (2×)"]
patchy-fx-gradient.af dpi=72;spreads=1;model_crc32=96cd10be;counts={"depth": 1, "images": 3, "nodes": 3};boards=[];fonts={};warnings=[]
patchy-fx.af dpi=72;spreads=1;model_crc32=7c8d4d20;counts={"depth": 1, "images": 5, "nodes": 5};boards=[];fonts={};warnings=[]
patchy-group.af dpi=72;spreads=1;model_crc32=dc37ab6c;counts={"depth": 2, "images": 3, "layers": 1, "nodes": 4};boards=[];fonts={};warnings=[]
patchy-incremental-chain.af dpi=72;spreads=1;model_crc32=38c9c5f4;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
patchy-lab.af dpi=72;spreads=1;model_crc32=df3a4e40;counts={"depth": 1, "nodes": 1, "unsupported": 1};boards=[];fonts={};warnings=["pixel layers in grey, Lab or 32-bit formats"]
patchy-lazy-placed.af dpi=72;spreads=1;model_crc32=8776a8a0;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
patchy-live-filter.af dpi=72;spreads=1;model_crc32=12235fe9;counts={"depth": 2, "fills": 2, "gradients": 0, "nodes": 4, "segments": 6, "shapes": 2, "strokes": 0, "subpaths": 2, "unsupported": 2};boards=[];fonts={};warnings=["adjustment layers", "unknown layers"]
patchy-rgba16.af dpi=72;spreads=1;model_crc32=688b2475;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
patchy-rgba8.af dpi=72;spreads=1;model_crc32=688b2475;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
patchy-shapes-2.af dpi=72;spreads=1;model_crc32=886e9852;counts={"depth": 1, "fills": 12, "gradients": 0, "nodes": 12, "segments": 62, "shapes": 12, "strokes": 0, "subpaths": 12};boards=[];fonts={};warnings=["cloud, heart, cog, callout, arrow, tear and other special shapes (imported as their bounding ellipse) (9×)"]
patchy-shapes.af dpi=72;spreads=1;model_crc32=c1790e97;counts={"depth": 1, "fills": 4, "gradients": 0, "nodes": 4, "segments": 28, "shapes": 4, "strokes": 0, "subpaths": 4};boards=[];fonts={};warnings=[]
patchy-text-artistic.af dpi=72;spreads=1;model_crc32=1d33192c;counts={"depth": 1, "nodes": 1, "text_chars": 5, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialMT:400:false"};warnings=[]
patchy-text-caps.af dpi=72;spreads=1;model_crc32=24e52518;counts={"depth": 1, "nodes": 2, "text_chars": 21, "text_runs": 2, "texts": 2};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font) (2×)"]
patchy-text-frame.af dpi=72;spreads=1;model_crc32=3bff8ef9;counts={"depth": 1, "nodes": 1, "text_chars": 11, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialMT:400:false"};warnings=[]
patchy-text-indent.af dpi=72;spreads=1;model_crc32=dc36dfc5;counts={"depth": 1, "nodes": 2, "text_chars": 104, "text_runs": 2, "texts": 2};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font) (2×)"]
patchy-text-para-spacing.af dpi=72;spreads=1;model_crc32=6408f39b;counts={"depth": 1, "nodes": 1, "text_chars": 13, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)"]
patchy-text-rotated.af dpi=72;spreads=1;model_crc32=3d1de559;counts={"depth": 1, "nodes": 1, "text_chars": 7, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)"]
patchy-text-runs.af dpi=72;spreads=1;model_crc32=7820b881;counts={"depth": 1, "nodes": 1, "text_chars": 7, "text_runs": 5, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false", "Courier New:CourierNewPSMT:400:false", "Segoe UI Emoji:SegoeUIEmoji:400:false", "Times New Roman:TimesNewRomanPSMT:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)", "text uses a stored fallback font (imported with that font)"]
patchy-transform.af dpi=72;spreads=1;model_crc32=8d56df84;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
patchy-vector-mask.af dpi=72;spreads=1;model_crc32=80ca946a;counts={"depth": 1, "fills": 2, "gradients": 0, "nodes": 2, "segments": 7, "shapes": 2, "strokes": 0, "subpaths": 2, "vector_masks": 1};boards=[];fonts={};warnings=["unknown layers attached to other layers (imported without them)"]
patchy-vector.af dpi=72;spreads=1;model_crc32=b36a9767;counts={"depth": 1, "fills": 3, "gradients": 0, "nodes": 4, "segments": 17, "shapes": 4, "strokes": 2, "subpaths": 5};boards=[];fonts={};warnings=[]
@@ -35,7 +35,12 @@ pub fn signature(doc: &Document) -> String {
}
"texts"
}
Kind::Table(t) => {
*counts.entry("table_cells").or_default() += t.cells.len();
"tables"
}
Kind::Image(_) => "images",
Kind::MasterInstance { .. } => "master_instances",
Kind::Unsupported => "unsupported",
};
*counts.entry(key).or_default() += 1;
@@ -140,7 +140,7 @@ pub const IDENTITY: [f64; 6] = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0];
// Shared arrays require definitions/references, whereas inline fields use Obj. Give the original
// fixture objects independent shared IDs without making the fixture descriptions noisy.
fn shared_objects(value: &mut F, next_id: &mut u32) {
pub fn shared_objects(value: &mut F, next_id: &mut u32) {
match value {
F::Shared(items) => {
for item in items {
+17 -5
View File
@@ -11,7 +11,7 @@ mod support {
use support::text_fixture::*;
use vectorcraft_affinity::{
Document, Limits,
model::{Align, Kind, Text},
model::{Align, Kind, Paragraph, Text},
paint::{Color, Paint},
synth::{F, tag},
};
@@ -108,7 +108,7 @@ fn mixed_paragraph_alignments_are_explicitly_reported() {
let d = read(vec![block(utf8("one\u{2029}"), vec![run(4, None)], &[2]), block(utf8("two"), vec![run(3, None)], &[0, 1])]);
assert_eq!(content(text(&d)), "one\u{2029}two");
assert_eq!(text(&d).align, Align::Right);
assert!(warning(&d, "mixed paragraph alignments"));
assert!(warning(&d, "mixed paragraph formats"));
}
#[test]
@@ -194,10 +194,22 @@ fn real_affinity3_text_fixtures_confirm_unicode_and_caps_fields() {
assert_eq!(texts.len(), 2);
assert!(texts[0].runs[0].all_caps);
assert_eq!(texts[1].runs[0].features, ["smcp"]);
for name in ["patchy-text-indent.af", "patchy-text-para-spacing.af"] {
// Indents and paragraph spacing are read (values in document pixels, as these fixtures set
// them in Affinity's paragraph panel).
let paragraphs = |name: &str| -> Vec<Paragraph> {
let d = load(name);
assert!(warning(&d, "paragraph indents or spacing"), "{name}: {:?}", d.warnings);
}
assert!(!warning(&d, "paragraph"), "{name}: {:?}", d.warnings);
d.spreads[0]
.nodes
.iter()
.filter_map(|n| if let Kind::Text(t) = &n.kind { Some(t) } else { None })
.flat_map(|t| t.runs.iter().map(|r| r.paragraph))
.collect()
};
let p = paragraphs("patchy-text-indent.af");
assert_eq!(p.iter().map(|p| (p.left, p.first, p.space_after)).collect::<Vec<_>>(), [(40.0, 0.0, 12.0), (0.0, 25.0, 12.0)]);
let p = paragraphs("patchy-text-para-spacing.af");
assert_eq!(p.iter().map(|p| (p.space_before, p.space_after)).collect::<Vec<_>>(), [(9.0, 17.0)]);
}
proptest::proptest! {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+84
View File
@@ -0,0 +1,84 @@
//! Local Affinity documents (never committed): every file under `AFFINITY_SAMPLES` (searched
//! recursively) opens as editable art, and each artboard is compared with the thumbnail Affinity
//! stored in the file. Reports the mean difference per artboard (0–255, both on white); with
//! `AFFINITY_SAMPLES_OUT` set, writes each board's thumbnail crop and render side by side there.
//! Skipped when `AFFINITY_SAMPLES` is unset (as in CI).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::path::{Path, PathBuf};
fn files(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else { return };
for e in entries.flatten() {
let p = e.path();
if p.is_dir() {
files(&p, out);
} else if p
.extension()
.and_then(|x| x.to_str())
.is_some_and(|x| ["af", "afdesign", "afpub", "afphoto"].contains(&x.to_ascii_lowercase().as_str()))
{
out.push(p);
}
}
}
fn on_white(p: &[u8]) -> [f64; 3] {
let a = f64::from(p[3]) / 255.0;
[0, 1, 2].map(|i| f64::from(p[i]) * a + 255.0 * (1.0 - a))
}
#[test]
fn local_affinity_documents_render_like_their_thumbnails() {
let Some(root) = std::env::var_os("AFFINITY_SAMPLES").map(PathBuf::from) else { return };
let out = std::env::var_os("AFFINITY_SAMPLES_OUT").map(PathBuf::from);
let mut paths = Vec::new();
files(&root, &mut paths);
paths.sort();
for path in paths {
let name = path.file_name().unwrap().to_string_lossy().to_string();
let bytes = std::fs::read(&path).unwrap();
let loaded = vectorcraft_engine::cmd::fileio::load(&name, &bytes).unwrap();
assert!(!loaded.preview_only, "{name}: {:?}", loaded.warnings);
let d = &loaded.doc;
eprintln!("{name}: {} artboards, {} layers", d.artboards.len(), d.layers.len());
for w in &loaded.warnings {
eprintln!(" {w}");
}
let Ok(preview) = vectorcraft_affinity::preview(&bytes) else { continue };
let thumb = image::load_from_memory(preview.png).unwrap().to_rgba8();
let union = d.artboards.iter().map(|a| a.rect).reduce(|a, b| a.union(b)).unwrap();
let scale = f64::from(thumb.width()) / union.width();
let mut total = 0.0;
for (i, board) in d.artboards.iter().enumerate() {
let r = board.rect;
let crop = |v: f64| (v * scale).round().max(0.0) as u32;
let (x, y) = (crop(r.x0 - union.x0).min(thumb.width() - 1), crop(r.y0 - union.y0).min(thumb.height() - 1));
let (w, h) = (crop(r.width()).min(thumb.width() - x).max(1), crop(r.height()).min(thumb.height() - y).max(1));
let expected = image::imageops::crop_imm(&thumb, x, y, w, h).to_image();
let k = (512.0 / r.width().max(r.height())).max(scale);
let rendered = vectorcraft_render::Renderer::new().render_region(d, r, k, false);
let big = image::RgbaImage::from_raw(rendered.width, rendered.height, rendered.to_straight()).unwrap();
let got = image::imageops::resize(&big, w, h, image::imageops::FilterType::Triangle);
let (mut sum, mut n) = (0.0, 0usize);
for (p, q) in expected.as_raw().as_chunks::<4>().0.iter().zip(got.as_raw().as_chunks::<4>().0.iter()) {
let (a, b) = (on_white(p), on_white(q));
sum += (0..3).map(|c| (a[c] - b[c]).abs()).sum::<f64>();
n += 3;
}
let diff = sum / n.max(1) as f64;
total += diff;
eprintln!(" board {i:3} {:<30} {diff:6.2}", board.name);
if let Some(dir) = &out {
let thumb_big = image::imageops::resize(&expected, big.width(), big.height(), image::imageops::FilterType::Triangle);
let mut pair = image::RgbaImage::from_pixel(big.width() * 2 + 8, big.height(), image::Rgba([255, 255, 255, 255]));
image::imageops::overlay(&mut pair, &thumb_big, 0, 0);
image::imageops::overlay(&mut pair, &big, i64::from(big.width()) + 8, 0);
let stem = path.file_stem().unwrap().to_string_lossy().to_string();
std::fs::create_dir_all(dir).unwrap();
pair.save(dir.join(format!("{stem}-{i:03}.png"))).unwrap();
}
}
eprintln!(" mean over boards: {:.2}", total / d.artboards.len().max(1) as f64);
}
}
+11 -7
View File
@@ -93,13 +93,17 @@ fn a_frame_too_short_for_its_first_line_is_made_taller_with_a_warning() {
// #1423: a title taller than its frame (60 pt type in a 50 pt frame) drew nothing.
let lines = |t: &TextObject| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).lines.len();
let title = |size: f64| import(vec![block(utf8("T"), vec![run(1, Some(attrs("Frame", "", 400, false, size)))], &[3])], true, 144.0, IDENTITY);
let short = title(120.0);
let t = texts(&short.doc)[0];
let TextKind::Area { frame } = &t.kind else { panic!("expected area type") };
let b = frame.bounds().unwrap();
assert!((b.x0, b.y0, b.width()) == (0.0, 0.0, 100.0) && b.height() > 50.0 && b.height() < 100.0, "taller from its top: {b:?}");
assert_eq!(lines(t), 1, "the line shows");
assert!(short.warnings.iter().any(|w| w.contains("too short for their first line")), "{:?}", short.warnings);
// Its baseline (at cap height) is inside the frame, its descenders hang out of it; and an
// 80 pt title whose capitals don't fit either.
for (size, warning, max) in [(120.0, "made taller so the lines show", 100.0), (160.0, "too short for their first line", 150.0)] {
let short = title(size);
let t = texts(&short.doc)[0];
let TextKind::Area { frame } = &t.kind else { panic!("expected area type") };
let b = frame.bounds().unwrap();
assert!((b.x0, b.y0, b.width()) == (0.0, 0.0, 100.0) && b.height() > 50.0 && b.height() < max, "{size}: taller from its top: {b:?}");
assert_eq!(lines(t), 1, "the line shows");
assert!(short.warnings.iter().any(|w| w.contains(warning)), "{size}: {:?}", short.warnings);
}
// A frame that holds its line keeps its height, and nothing is said.
let fits = title(20.0);
let t = texts(&fits.doc)[0];
+5
View File
@@ -216,6 +216,11 @@ impl FontFace {
pub fn vertical_metrics(&self) -> (f64, f64) {
(self.ascent, self.descent)
}
/// The line gap the font asks for between lines, in font units (from the same table as
/// [`vertical_metrics`](Self::vertical_metrics); 0 when it has none).
pub fn line_gap(&self) -> f64 {
self.skrifa().map_or(0.0, |f| f64::from(f.metrics(Size::unscaled(), self.location()).leading)).max(0.0)
}
/// Every mapped character and its glyph id, sorted by code point (the Glyphs panel).
pub fn chars(&self) -> Vec<(char, u32)> {
let Some(f) = self.skrifa() else { return vec![] };
+2 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long