mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-05 23:50:03 -06:00
Merge pull request #3548 from dwetscher/feat/affinity-designcraft-port
AF.5: Affinity import — strokes, text decoration, text placement, outline effects, CMYK photos
This commit is contained in:
@@ -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
File diff suppressed because one or more lines are too long
+50
-18
@@ -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; their master pages are left out); 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 (a group's over its children's) |
|
||||
| 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.
|
||||
@@ -104,9 +119,14 @@ The reader also has an independent copy in PhotoCraft's `photocraft-affinity`.
|
||||
pass CI. Run locally with `AFFINITY_CORPUS_REQUIRED=1` for the same strict gate.
|
||||
* Synthetic engine regressions cover nested, rotated and curved artboards, negative origins,
|
||||
multiple spreads and resolution conversion, alongside text recovery and native save/export.
|
||||
* `engine/tests/import_fuzz.rs` mutates a synthetic native document's stream and archive and checks
|
||||
that whatever opens also renders and exports; `fuzz/` has `cargo-fuzz` targets for the whole
|
||||
reader (`container`) and the object stream (`stream`):
|
||||
* `engine/tests/import_fuzz.rs` mutates a synthetic native document's stream and archive, and
|
||||
documents with layer effects, colour overlays (also over tables), line styles, paragraph and
|
||||
character formats, tables, master pages, CMYK pixel layers and embedded documents (the embedding
|
||||
and the embedded stream), and checks that whatever opens also renders and exports.
|
||||
`tests/import_limits.rs` builds hostile documents against the limits below: grids of 16 million
|
||||
cells, grids and stories shared by many layers, 200 000 paragraphs, a document embedded hundreds
|
||||
of times. `fuzz/` has `cargo-fuzz` targets for the whole reader (`container`) and the object
|
||||
stream (`stream`):
|
||||
|
||||
```sh
|
||||
cd crates/affinity
|
||||
@@ -132,11 +152,23 @@ 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
|
||||
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.
|
||||
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; pixel masks are read only over what
|
||||
they mask (all of it under artistic text, whose glyphs only the layout places). One budget covers
|
||||
the whole read: layers, pixels, 16 million characters of stories and 262 144 table cells, so
|
||||
content that many layers link, or a document embedded many times, can't multiply them. Embedded
|
||||
documents nest four deep at most; both of their readings are charged to that budget and to the
|
||||
importing document's archive limits, also when they fail part of the way. Tables have at most
|
||||
4096 rows and columns and 65 536 cells, 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 (256 bytes at most each),
|
||||
features and gradient stops; paragraph formats are matched to runs by binary search. Exhaustion
|
||||
warns and uses default attributes for remaining text, preserving its characters. Colour and
|
||||
gradient overlays share one work budget per import (`overlay::MAX_WORK`: the nodes and pixels
|
||||
each is baked into); past it they are left out, and said. In VectorCraft, master pages are
|
||||
copied onto at most as many objects, characters and pixels as a document may hold, a few times
|
||||
over, and objects beyond `MAX_COORD` of the origin are left out.
|
||||
Every archive entry's CRC-32 and size must match. Malformed input returns an `Error`; the
|
||||
crate has no panics outside tests.
|
||||
|
||||
|
||||
@@ -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"))?;
|
||||
|
||||
@@ -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;
|
||||
|
||||
+625
-59
@@ -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,69 @@ 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, and
|
||||
/// the list is empty when the file's didn't match the grid.
|
||||
pub vertical: Vec<Option<Stroke>>,
|
||||
/// Horizontal edges boundary by boundary (one more than the rows), one per column (empty
|
||||
/// as `vertical` can be).
|
||||
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 +340,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 +425,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 +448,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 +472,32 @@ 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;
|
||||
/// Characters of all stories together (a story may be shared by many text layers).
|
||||
const MAX_DOCUMENT_CHARS: usize = 16 << 20;
|
||||
/// Table cells of all tables together (each becomes a text object with its edge lines).
|
||||
const MAX_DOCUMENT_CELLS: usize = 1 << 18;
|
||||
|
||||
/// What a whole read may still use. Shared objects can be linked from many layers, and embedded
|
||||
/// documents are read with what their embedding document left, so linking or embedding the
|
||||
/// same content many times can't multiply the work.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct Budget {
|
||||
/// Layer nodes ([`MAX_NODES`] in all).
|
||||
pub(crate) nodes: usize,
|
||||
/// Pixels the pixel layers may decode ([`MAX_LAYER_PIXELS`] in all).
|
||||
pub(crate) pixels: u64,
|
||||
/// Characters of stories ([`MAX_DOCUMENT_CHARS`] in all).
|
||||
pub(crate) chars: usize,
|
||||
/// Table cells ([`MAX_DOCUMENT_CELLS`] in all).
|
||||
pub(crate) cells: usize,
|
||||
}
|
||||
|
||||
impl Budget {
|
||||
pub(crate) const FULL: Budget = Budget { nodes: MAX_NODES, pixels: MAX_LAYER_PIXELS, chars: MAX_DOCUMENT_CHARS, cells: MAX_DOCUMENT_CELLS };
|
||||
}
|
||||
|
||||
/// 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 +505,81 @@ 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> {
|
||||
let mut archive = Archive::open(bytes, limits)?;
|
||||
read_at(bytes, limits, &mut Budget { pixels, ..Budget::FULL }, Affine::IDENTITY, 0).document
|
||||
}
|
||||
|
||||
/// A document read by [`read_at`] (or why it couldn't be), with the bytes its archive extracted
|
||||
/// either way.
|
||||
struct ReadAt {
|
||||
document: Result<Document, Error>,
|
||||
extracted: usize,
|
||||
}
|
||||
|
||||
/// Read a document whose spreads are placed by `base` (for embedded documents, nested `depth`
|
||||
/// deep). What it uses is taken from `budget`, also when it fails part of the way.
|
||||
fn read_at(bytes: &[u8], limits: Limits, budget: &mut Budget, base: Affine, depth: usize) -> ReadAt {
|
||||
let mut archive = match Archive::open(bytes, limits) {
|
||||
Ok(a) => a,
|
||||
Err(e) => return ReadAt { document: Err(e), extracted: 0 },
|
||||
};
|
||||
let document = read_archive(&mut archive, budget, base, depth);
|
||||
ReadAt { document, extracted: archive.extracted() }
|
||||
}
|
||||
|
||||
fn read_archive(archive: &mut Archive, budget: &mut Budget, base: Affine, depth: usize) -> Result<Document, Error> {
|
||||
let doc = archive.read("doc.dat")?;
|
||||
let s = stream::parse(&doc)?;
|
||||
let mut reader = Reader::new(&s, &mut archive);
|
||||
reader.pixels_left = pixels;
|
||||
reader.document()
|
||||
let mut reader = Reader::new(&s, archive, base, depth);
|
||||
reader.budget = *budget;
|
||||
let document = reader.document();
|
||||
*budget = reader.budget;
|
||||
document
|
||||
}
|
||||
|
||||
pub(crate) struct Reader<'s, 'a, 'b> {
|
||||
pub(crate) s: &'s Stream,
|
||||
pub(crate) archive: &'b mut Archive<'a>,
|
||||
pub(crate) dpi: f64,
|
||||
warnings: BTreeMap<String, usize>,
|
||||
nodes: usize,
|
||||
pub(crate) warnings: BTreeMap<String, usize>,
|
||||
active: HashSet<ObjId>,
|
||||
/// Pixels the pixel layers still to come may decode ([`MAX_LAYER_PIXELS`] in all).
|
||||
pub(crate) pixels_left: u64,
|
||||
/// 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>,
|
||||
/// What the rest of the read may use.
|
||||
pub(crate) budget: Budget,
|
||||
}
|
||||
|
||||
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(),
|
||||
active: HashSet::new(),
|
||||
base,
|
||||
type_base: Affine([a, b, c, d, 0.0, 0.0]),
|
||||
depth,
|
||||
pending: None,
|
||||
master_ids: Vec::new(),
|
||||
budget: Budget::FULL,
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
fn document(&mut self) -> Result<Document, Error> {
|
||||
let s = self.s;
|
||||
let root = s.root;
|
||||
self.dpi = s.obj(root, b"UVCn").and_then(|u| s.f64(u, b"UPPI")).filter(|d| *d > 0.0 && *d < 100_000.0).unwrap_or(72.0);
|
||||
@@ -347,40 +591,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 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 })
|
||||
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 = std::mem::take(&mut self.warnings).into_iter().map(|(w, n)| if n > 1 { format!("{w} ({n}×)") } else { w }).collect();
|
||||
Ok(Document { dpi: self.dpi, spreads, masters, saved_by, warnings, cmyk_profile })
|
||||
}
|
||||
|
||||
/// 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 +660,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;
|
||||
}
|
||||
@@ -411,10 +680,10 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
self.warn("layers nested deeper than 128 levels");
|
||||
return Ok(None);
|
||||
}
|
||||
self.nodes += 1;
|
||||
if self.nodes > MAX_NODES {
|
||||
let Some(left) = self.budget.nodes.checked_sub(1) else {
|
||||
return Err(Error::Limit("too many layers"));
|
||||
}
|
||||
};
|
||||
self.budget.nodes = left;
|
||||
// A node that contains itself (a shared object linked back into its own subtree).
|
||||
if !self.active.insert(id) {
|
||||
self.warn("a layer that contains itself");
|
||||
@@ -441,15 +710,28 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
} else {
|
||||
self.kind(id, class, world, parent)?
|
||||
};
|
||||
// This node's embedded content, before its children's `node` calls can take it.
|
||||
let embedded = self.pending.take();
|
||||
// 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) = embedded {
|
||||
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 = mask_extent(&kind, &children);
|
||||
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, also while it is hidden.
|
||||
let bounds = tree_bounds(&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 +753,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 +771,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 +824,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 +923,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 +935,93 @@ 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.
|
||||
// Both readings are charged to the budget and the archive's limits, also when they fail:
|
||||
// each does the work.
|
||||
let mut budget = self.budget;
|
||||
let probe = read_at(inner, self.archive.remaining(), &mut budget, Affine::IDENTITY, self.depth + 1);
|
||||
self.budget = budget;
|
||||
if self.archive.charge(probe.extracted).is_err() {
|
||||
self.warn("embedded documents beyond the size limit (left out)");
|
||||
return None;
|
||||
}
|
||||
let probe = match probe.document {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
self.warn(format!("embedded documents that could not be read ({e})"));
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let spread = probe.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 pixels aren't needed any more: never hold both readings' at once.
|
||||
drop(probe);
|
||||
// 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;
|
||||
}
|
||||
let mut budget = self.budget;
|
||||
let placed = read_at(inner, self.archive.remaining(), &mut budget, base, self.depth + 1);
|
||||
self.budget = budget;
|
||||
if self.archive.charge(placed.extracted).is_err() {
|
||||
self.warn("embedded documents beyond the size limit (left out)");
|
||||
return None;
|
||||
}
|
||||
let doc = placed.document.ok()?;
|
||||
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)");
|
||||
}
|
||||
let mut nodes = doc.spreads.into_iter().next()?.nodes;
|
||||
// Its master page instances count its own masters, which aren't kept: in this document
|
||||
// they would show other masters.
|
||||
fn strip_masters(nodes: &mut [Node], depth: usize) -> bool {
|
||||
let mut found = false;
|
||||
for n in nodes {
|
||||
if matches!(n.kind, Kind::MasterInstance { .. }) {
|
||||
n.kind = Kind::Unsupported;
|
||||
found = true;
|
||||
}
|
||||
if depth < MAX_TREE_DEPTH {
|
||||
found |= strip_masters(&mut n.children, depth + 1);
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
if strip_masters(&mut nodes, 0) {
|
||||
self.warn("master pages of embedded documents (left out)");
|
||||
}
|
||||
Some(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 +1056,96 @@ 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> {
|
||||
let mut out = None;
|
||||
walk_bounds(nodes, true, 0, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// The bounds of `node` and everything in it, hidden or not.
|
||||
fn tree_bounds(node: &Node) -> Option<Rect> {
|
||||
let mut out = None;
|
||||
walk_bounds(std::slice::from_ref(node), false, 0, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn walk_bounds(nodes: &[Node], visible_only: bool, depth: usize, out: &mut Option<Rect>) {
|
||||
if depth > MAX_TREE_DEPTH {
|
||||
return;
|
||||
}
|
||||
for n in nodes.iter().filter(|n| n.visible || !visible_only) {
|
||||
kind_points(&n.kind, &mut |p| add_point(out, p));
|
||||
walk_bounds(&n.children, visible_only, depth + 1, out);
|
||||
}
|
||||
}
|
||||
|
||||
/// What a pixel mask over a node of `kind` with `children` is read over: the bounds of what they
|
||||
/// draw, strokes included, or `None` (all of the mask) when some of it has no bounds known here:
|
||||
/// artistic text, whose glyphs only the layout places.
|
||||
fn mask_extent(kind: &Kind, children: &[Node]) -> Option<Rect> {
|
||||
fn visit(kind: &Kind, children: &[Node], depth: usize, out: &mut Option<Rect>) -> bool {
|
||||
if depth > MAX_TREE_DEPTH || matches!(kind, Kind::Text(t) if t.frame.is_none()) {
|
||||
return false;
|
||||
}
|
||||
let mut own = kind_bounds(kind);
|
||||
if let (Some(r), Kind::Shape { strokes, .. } | Kind::Artboard { strokes, .. }) = (own, kind) {
|
||||
// As far as a stroke can reach out of its path, miter joins included.
|
||||
let reach = strokes.iter().map(|s| s.width * s.miter_limit.clamp(1.0, 10.0)).filter(|w| w.is_finite()).fold(0.0, f64::max);
|
||||
own = Some(Rect { x0: r.x0 - reach, y0: r.y0 - reach, x1: r.x1 + reach, y1: r.y1 + reach });
|
||||
}
|
||||
if let Some(r) = own {
|
||||
*out = Some(out.map_or(r, |o| union(o, r)));
|
||||
}
|
||||
children.iter().filter(|n| n.visible).all(|n| visit(&n.kind, &n.children, depth + 1, out))
|
||||
}
|
||||
let mut out = None;
|
||||
if visit(kind, children, 0, &mut out) { out } else { None }
|
||||
}
|
||||
|
||||
/// The bounds of what one node's own kind draws (not its children): its outline's points, its
|
||||
/// image's or frame's corners, or artistic text's anchor.
|
||||
pub 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> {
|
||||
|
||||
@@ -0,0 +1,663 @@
|
||||
//! 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;
|
||||
|
||||
/// Work the overlays of one import may take together: each overlay counts the nodes and pixels
|
||||
/// of the subtree it is baked into (which is copied once more for them all). Nested overlays
|
||||
/// and shared content would otherwise multiply it; past it, overlays are left out and said.
|
||||
pub const MAX_WORK: u64 = 1 << 29;
|
||||
|
||||
#[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, and those of everything in it
|
||||
/// (borrowed unchanged when `node` has none, or when what is left of `work`, see [`MAX_WORK`],
|
||||
/// can't pay for copying it). `warn` hears what could only be approximated or was left out.
|
||||
pub fn apply<'a>(node: &'a Node, work: &mut u64, warn: &mut dyn FnMut(&str)) -> Cow<'a, Node> {
|
||||
if overlays(&node.effects).is_empty() {
|
||||
return Cow::Borrowed(node);
|
||||
}
|
||||
let Some(left) = work.checked_sub(subtree_cost(node, *work, 0)) else {
|
||||
warn("overlay effects beyond the work one import may take (left out)");
|
||||
return Cow::Borrowed(node);
|
||||
};
|
||||
*work = left;
|
||||
let mut out = node.clone();
|
||||
bake_tree(&mut out, work, 0, warn);
|
||||
Cow::Owned(out)
|
||||
}
|
||||
|
||||
/// The visible colour and gradient overlays among `effects`.
|
||||
fn overlays(effects: &[Effect]) -> Vec<Overlay<'_>> {
|
||||
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()
|
||||
}
|
||||
|
||||
fn is_overlay(e: &Effect) -> bool {
|
||||
matches!(e, Effect::ColorOverlay { .. } | Effect::GradientOverlay { .. })
|
||||
}
|
||||
|
||||
/// Bakes the overlays of `node` and its subtree into their paint, innermost first: an overlay
|
||||
/// lies over what its node shows, its children's overlays included. Each overlay pays for the
|
||||
/// subtree it is baked into out of `work`; past it, it is left out (and said).
|
||||
fn bake_tree(node: &mut Node, work: &mut u64, depth: usize, warn: &mut dyn FnMut(&str)) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
for c in &mut node.children {
|
||||
bake_tree(c, work, depth + 1, warn);
|
||||
}
|
||||
if !node.effects.iter().any(is_overlay) {
|
||||
return;
|
||||
}
|
||||
let (effects, rest): (Vec<Effect>, Vec<Effect>) = std::mem::take(&mut node.effects).into_iter().partition(is_overlay);
|
||||
node.effects = rest;
|
||||
let overlays = overlays(&effects);
|
||||
if overlays.is_empty() {
|
||||
return;
|
||||
}
|
||||
let count = u64::try_from(overlays.len()).unwrap_or(u64::MAX);
|
||||
let cost = subtree_cost(node, *work / count, 0).saturating_mul(count);
|
||||
let Some(left) = work.checked_sub(cost) else {
|
||||
warn("overlay effects beyond the work one import may take (left out)");
|
||||
return;
|
||||
};
|
||||
*work = left;
|
||||
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(node, o, 0, warn);
|
||||
}
|
||||
}
|
||||
|
||||
/// Nodes and pixels in `node`'s subtree, counted only until they pass `limit`.
|
||||
fn subtree_cost(node: &Node, limit: u64, depth: usize) -> u64 {
|
||||
if depth > MAX_DEPTH {
|
||||
return 0;
|
||||
}
|
||||
let mut cost: u64 = match &node.kind {
|
||||
Kind::Image(img) => (u64::from(img.width) * u64::from(img.height)).saturating_add(1),
|
||||
Kind::Text(t) => u64::try_from(t.runs.len()).unwrap_or(u64::MAX).saturating_add(1),
|
||||
Kind::Table(t) => u64::try_from(t.cells.len()).unwrap_or(u64::MAX).saturating_add(1),
|
||||
_ => 1,
|
||||
};
|
||||
for c in &node.children {
|
||||
if cost > limit {
|
||||
break;
|
||||
}
|
||||
cost = cost.saturating_add(subtree_cost(c, limit - cost, depth + 1));
|
||||
}
|
||||
cost
|
||||
}
|
||||
|
||||
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);
|
||||
if let Some(s) = &mut r.stroke {
|
||||
stroke(s, 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 decode(bytes, img.width, img.height) {
|
||||
Some(p) => p,
|
||||
None => 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
|
||||
}
|
||||
|
||||
/// An encoded original's RGBA pixels, decoded only if it is `width` × `height` as its layer says.
|
||||
fn decode(bytes: &[u8], width: u32, height: u32) -> Option<Vec<u8>> {
|
||||
let probe = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?;
|
||||
if probe.into_dimensions().ok()? != (width, height) {
|
||||
return None;
|
||||
}
|
||||
let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?;
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_image_width = Some(width);
|
||||
limits.max_image_height = Some(height);
|
||||
limits.max_alloc = u64::try_from(MAX_PIXELS).ok().map(|n| n * 8);
|
||||
reader.limits(limits);
|
||||
Some(reader.decode().ok()?.into_rgba8().into_raw())
|
||||
}
|
||||
|
||||
/// 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 { MAX_WORK }, &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 { MAX_WORK }, &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 { MAX_WORK }, &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 { MAX_WORK }, &mut |_| {}).into_owned();
|
||||
let Kind::Shape { fills, .. } = &out.kind else { panic!() };
|
||||
assert_eq!(fills.first(), Some(&Paint::Gradient(g)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_group_overlay_lies_over_its_childrens_overlays() {
|
||||
let red = Color::Rgb { r: 1.0, g: 0.0, b: 0.0, a: 1.0 };
|
||||
let blue = Color::Rgb { r: 0.0, g: 0.0, b: 1.0, a: 1.0 };
|
||||
let mut group = solid(red, 0.5, Blend::Normal);
|
||||
group.kind = Kind::Group;
|
||||
group.children = vec![solid(blue, 1.0, Blend::Normal)];
|
||||
// The copy (2 nodes), the child's overlay (1) and the group's (2).
|
||||
let mut work = 5;
|
||||
let out = apply(&group, &mut work, &mut |_| {}).into_owned();
|
||||
assert_eq!(work, 0);
|
||||
let child = out.children.first().unwrap();
|
||||
// Blue first, then half of the group's red over it.
|
||||
assert_eq!(fill(child), Color::Rgb { r: 0.5, g: 0.0, b: 0.5, a: 1.0 });
|
||||
assert!(out.effects.is_empty() && child.effects.is_empty(), "both overlays are baked");
|
||||
// With no work left for the group's overlay, the child's still applies.
|
||||
let mut warnings = Vec::new();
|
||||
let out = apply(&group, &mut { 4 }, &mut |w| warnings.push(w.to_string())).into_owned();
|
||||
assert_eq!(fill(out.children.first().unwrap()), blue);
|
||||
assert!(warnings.iter().any(|w| w.contains("beyond the work")), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlays_stop_at_the_work_one_import_may_take() {
|
||||
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: 4, height: 4, pixels: Pixels::Rgba8(vec![255; 64]), transform: Affine::IDENTITY });
|
||||
// 16 pixels and the node, for the copy and for the overlay: 34.
|
||||
let mut work = 40;
|
||||
let mut warnings = Vec::new();
|
||||
assert!(matches!(apply(&n, &mut work, &mut |w| warnings.push(w.to_string())), Cow::Owned(_)));
|
||||
assert_eq!((work, warnings.len()), (6, 0));
|
||||
assert!(matches!(apply(&n, &mut work, &mut |w| warnings.push(w.to_string())), Cow::Borrowed(_)));
|
||||
assert_eq!(work, 6);
|
||||
assert!(warnings.iter().any(|w| w.contains("beyond the work")), "{warnings:?}");
|
||||
}
|
||||
}
|
||||
@@ -126,6 +126,9 @@ pub struct Stroke {
|
||||
/// Gradient stops per gradient.
|
||||
const MAX_STOPS: usize = 1024;
|
||||
|
||||
/// Dash and gap lengths per dash pattern (a pattern has a few; strokes and text runs copy theirs).
|
||||
const MAX_DASHES: usize = 64;
|
||||
|
||||
fn floats(v: &Value) -> Option<Vec<f64>> {
|
||||
match v {
|
||||
Value::Floats(f) => Some(f.clone()),
|
||||
@@ -283,7 +286,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 +298,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
|
||||
@@ -312,6 +322,10 @@ fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, behind: bool)
|
||||
_ => Align::Center,
|
||||
};
|
||||
let dash = match s.field(lsty, b"Patn") {
|
||||
Some(Value::Array(a)) if a.len() > MAX_DASHES => {
|
||||
r.warn("dash patterns of more than 64 lengths (imported as solid lines)");
|
||||
None
|
||||
}
|
||||
Some(Value::Array(a)) if data.get(10) == Some(&2) || !a.is_empty() => {
|
||||
let mut v: Vec<f64> = a.iter().filter_map(|x| if let Value::Float(f) = x { Some(*f) } else { None }).collect();
|
||||
while v.len() >= 2 && v.iter().rev().take(2).all(|x| *x == 0.0) {
|
||||
|
||||
+246
-59
@@ -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,33 +145,41 @@ 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 Some(left) = r.pixels_left.checked_sub(pixels) else {
|
||||
let pixels = u64::from(w.div_ceil(step)) * u64::from(h.div_ceil(step));
|
||||
let Some(left) = r.budget.pixels.checked_sub(pixels) else {
|
||||
r.warn("pixel layers beyond the pixels one document may hold");
|
||||
return Ok(None);
|
||||
};
|
||||
r.pixels_left = left;
|
||||
r.budget.pixels = left;
|
||||
let format = s.enumeration(bitmap, b"Frmt").map(|(f, _)| f);
|
||||
let (channels, bps) = match format {
|
||||
Some(0) => (4, 1),
|
||||
Some(1) => (4, 2),
|
||||
Some(6) => (1, 1),
|
||||
Some(7) => (1, 2),
|
||||
Some(4) => {
|
||||
r.warn("CMYK pixel layers (converted to RGB without the document's colour profile)");
|
||||
(5, 1)
|
||||
}
|
||||
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]
|
||||
|
||||
+330
-99
@@ -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,256 @@ 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;
|
||||
/// Cells a table may have (rows × columns): each becomes a text object with its edge lines.
|
||||
const MAX_TABLE_CELLS: usize = 1 << 16;
|
||||
|
||||
/// 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)?;
|
||||
if count > MAX_TABLE_CELLS {
|
||||
r.warn("tables with more than 65536 cells (left out)");
|
||||
return None;
|
||||
}
|
||||
let Some(left) = r.budget.cells.checked_sub(count) else {
|
||||
r.warn("tables beyond the cells one document may hold (left out)");
|
||||
return None;
|
||||
};
|
||||
r.budget.cells = left;
|
||||
let cell_objs = s.obj(tabl, b"Cell").map(|c| s.objs(c, b"Cell")).unwrap_or_default();
|
||||
// Formatting that doesn't match the grid would land on the wrong cells (and merging them,
|
||||
// move text between cells): only text then.
|
||||
let cell_objs = if cell_objs.len() == count {
|
||||
cell_objs
|
||||
} else {
|
||||
r.warn("tables whose cells don't match their grid (cell formatting left out)");
|
||||
Vec::new()
|
||||
};
|
||||
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::new();
|
||||
}
|
||||
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:
|
||||
// by the story's limit and by what the document's other stories left.
|
||||
let budget = (MAX_CHARS - total).min(r.budget.chars.saturating_sub(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);
|
||||
if budget < MAX_CHARS - total {
|
||||
r.warn("text beyond the characters one document may hold (truncated)");
|
||||
} else {
|
||||
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)"),
|
||||
_ => {}
|
||||
},
|
||||
}
|
||||
}
|
||||
// Sorted by end (they are in order in every file), so both lookups below are binary
|
||||
// searches: a block can have as many paragraphs and runs as characters.
|
||||
paragraphs.sort_by_key(|(end, _)| *end);
|
||||
let align_at = |i: usize| paragraphs.get(paragraphs.partition_point(|(end, _)| *end <= i)).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.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 inside = cuts.get(cuts.partition_point(|c| *c <= from)..cuts.partition_point(|c| *c < to)).unwrap_or_default();
|
||||
let mut a = from;
|
||||
for &c in inside.iter().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;
|
||||
}
|
||||
}
|
||||
r.budget.chars = r.budget.chars.saturating_sub(total);
|
||||
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 +298,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 +361,33 @@ 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 mut 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,
|
||||
};
|
||||
// Its gradient stops and dashes are owned data too, charged like the fill's.
|
||||
if let Some(st) = &stroke {
|
||||
let stops = match &st.paint {
|
||||
Paint::Gradient(g) => g.stops.len().saturating_mul(std::mem::size_of::<paint::Stop>()),
|
||||
_ => 0,
|
||||
};
|
||||
let dash = st.dash.as_ref().map_or(0, |(d, _)| d.len().saturating_mul(std::mem::size_of::<f64>()));
|
||||
if !charge_attributes(r, attribute_budget, stops.saturating_add(dash)) {
|
||||
stroke = 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
|
||||
@@ -220,8 +406,8 @@ fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine, attribut
|
||||
if font.and_then(|f| s.int(f, b"Widh")).is_some_and(|w| w != 5) {
|
||||
r.warn("font width variants (exact face depends on the installed PostScript font)");
|
||||
}
|
||||
let postscript = metadata_string(r, attribute_budget, font.and_then(|f| s.str(f, b"Post")).unwrap_or_default());
|
||||
let family = metadata_string(r, attribute_budget, font.and_then(|f| s.str(f, b"Famy")).unwrap_or_default());
|
||||
let postscript = font_name(r, attribute_budget, font.and_then(|f| s.str(f, b"Post")).unwrap_or_default());
|
||||
let family = font_name(r, attribute_budget, font.and_then(|f| s.str(f, b"Famy")).unwrap_or_default());
|
||||
let (features, all_caps) = font_features(r, objects.get(7).copied().flatten(), attribute_budget);
|
||||
let weight = font.and_then(|f| s.int(f, b"Wegt")).unwrap_or(400);
|
||||
let weight = if (1..=1000).contains(&weight) {
|
||||
@@ -238,10 +424,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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,6 +451,17 @@ fn charge_attributes(r: &mut Reader, budget: &mut Option<usize>, bytes: usize) -
|
||||
}
|
||||
}
|
||||
|
||||
/// Longest font name kept (OpenType names are far shorter); every run and paragraph carries it.
|
||||
const MAX_FONT_NAME: usize = 256;
|
||||
|
||||
fn font_name(r: &mut Reader, budget: &mut Option<usize>, value: &str) -> String {
|
||||
if value.len() > MAX_FONT_NAME {
|
||||
r.warn("font names longer than 256 bytes (the default font is used)");
|
||||
return String::new();
|
||||
}
|
||||
metadata_string(r, budget, value)
|
||||
}
|
||||
|
||||
fn metadata_string(r: &mut Reader, budget: &mut Option<usize>, value: &str) -> String {
|
||||
let bytes = value.len().saturating_add(std::mem::size_of::<String>());
|
||||
if value.is_empty() || !charge_attributes(r, budget, bytes) { String::new() } else { value.to_owned() }
|
||||
@@ -303,13 +505,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 +525,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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
//! Crafted documents can't make the reader allocate or work without bound: tables, shared
|
||||
//! stories, paragraphs and embedded documents stay within the whole read's budget, however often
|
||||
//! a file links or embeds the same content. Original synthetic documents in the reader's
|
||||
//! accepted schema; no Affinity software or files were used.
|
||||
#![cfg(feature = "synth")]
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
mod support {
|
||||
pub mod text_fixture;
|
||||
}
|
||||
|
||||
use support::text_fixture::*;
|
||||
use vectorcraft_affinity::{
|
||||
Document, Error, Limits,
|
||||
model::{Kind, Node},
|
||||
read,
|
||||
synth::{self, F, Method, tag},
|
||||
};
|
||||
|
||||
type Field = (vectorcraft_affinity::stream::Tag, F);
|
||||
|
||||
/// A document of one spread holding `nodes`, plus these other archive entries.
|
||||
fn file(nodes: Vec<F>, entries: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
let spread = F::Obj(tag(b"Sprd"), vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 400.0, 300.0])), (tag(b"Chld"), F::Shared(nodes))]);
|
||||
let mut root = vec![(tag(b"DocR"), F::Obj(tag(b"DocN"), vec![(tag(b"Chld"), F::Shared(vec![spread]))]))];
|
||||
let mut next_id = 1000;
|
||||
for (_, value) in &mut root {
|
||||
shared_objects(value, &mut next_id);
|
||||
}
|
||||
let data = synth::stream(&root);
|
||||
let mut all = vec![("doc.dat", data.as_slice(), Method::Zlib)];
|
||||
all.extend(entries.iter().map(|(name, bytes)| (*name, *bytes, Method::Stored)));
|
||||
synth::container(&all, None)
|
||||
}
|
||||
|
||||
fn open(nodes: Vec<F>) -> Document {
|
||||
read(&file(nodes, &[]), Limits::default()).unwrap()
|
||||
}
|
||||
|
||||
fn all(nodes: &[Node]) -> Vec<&Node> {
|
||||
let mut out = Vec::new();
|
||||
for n in nodes {
|
||||
out.push(n);
|
||||
out.extend(all(&n.children));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn warned(d: &Document, needle: &str) -> bool {
|
||||
d.warnings.iter().any(|w| w.contains(needle))
|
||||
}
|
||||
|
||||
fn story(text: &str) -> F {
|
||||
F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![block(utf8(text), vec![run(text.chars().count() as i32, None)], &[])]))])
|
||||
}
|
||||
|
||||
fn artistic(story: F) -> F {
|
||||
F::Obj(
|
||||
tag(b"TxtA"),
|
||||
vec![
|
||||
(tag(b"StSt"), story),
|
||||
(tag(b"TxtH"), F::Obj(tag(b"ArFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 100.0, 20.0])), (tag(b"ArtV"), F::F64(16.0))])),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// A table frame (`TbFr`) with a grid of `cols` × `rows` cells and no cell objects or lines.
|
||||
fn grid(cols: usize, rows: usize) -> Vec<Field> {
|
||||
let pos = |n: usize| {
|
||||
let v: Vec<f64> = (0..=n).map(|i| i as f64).collect();
|
||||
F::Obj(tag(b"TPos"), vec![(tag(b"Posn"), array_f64(b"Posn", &v))])
|
||||
};
|
||||
let table = F::Obj(tag(b"Tabl"), vec![(tag(b"CPos"), pos(cols)), (tag(b"RPos"), pos(rows))]);
|
||||
vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, cols as f64, rows as f64])), (tag(b"Tabl"), table)]
|
||||
}
|
||||
|
||||
fn table(frame: F) -> F {
|
||||
F::Obj(tag(b"TxtT"), vec![(tag(b"StSt"), story("A")), (tag(b"TxtH"), frame)])
|
||||
}
|
||||
|
||||
fn tables(d: &Document) -> Vec<&vectorcraft_affinity::model::Table> {
|
||||
all(&d.spreads[0].nodes).into_iter().filter_map(|n| if let Kind::Table(t) = &n.kind { Some(t) } else { None }).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_table_of_more_than_65536_cells_is_left_out_before_its_cells_are_made() {
|
||||
// 4096 × 4096 is within each dimension's limit but 16.7 million cells.
|
||||
let d = open(vec![table(F::Obj(tag(b"TbFr"), grid(4096, 4096))), table(F::Obj(tag(b"TbFr"), grid(256, 256)))]);
|
||||
let t = tables(&d);
|
||||
assert_eq!(t.len(), 1);
|
||||
assert_eq!((t[0].cells.len(), t[0].vertical.len(), t[0].horizontal.len()), (65536, 0, 0), "no lines when they don't match");
|
||||
assert!(warned(&d, "tables with more than 65536 cells"), "{:?}", d.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tables_sharing_one_grid_stay_within_the_cells_of_one_document() {
|
||||
// One 65536-cell grid linked from five tables: four fill the document's 262144 cells.
|
||||
let shared = F::Def(2_000_000, vec![tag(b"TbFr")], grid(256, 256));
|
||||
let d = open((0..5).map(|i| table(if i == 0 { shared.clone() } else { F::Ref(2_000_000) })).collect());
|
||||
assert_eq!(tables(&d).len(), 4);
|
||||
assert!(warned(&d, "tables beyond the cells one document may hold"), "{:?}", d.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shared_story_is_read_within_the_characters_of_one_document() {
|
||||
// A story of a million characters linked from twenty text layers: 16 million in all.
|
||||
let text = "x".repeat(1 << 20);
|
||||
let shared = F::Def(2_000_000, vec![tag(b"Stry")], vec![(tag(b"Blok"), F::Shared(vec![block(utf8(&text), vec![run(1 << 20, None)], &[])]))]);
|
||||
let d = open((0..20).map(|i| artistic(if i == 0 { shared.clone() } else { F::Ref(2_000_000) })).collect());
|
||||
let chars: usize = all(&d.spreads[0].nodes)
|
||||
.into_iter()
|
||||
.filter_map(|n| if let Kind::Text(t) = &n.kind { Some(t.runs.iter().map(|r| r.text.len()).sum::<usize>()) } else { None })
|
||||
.sum();
|
||||
assert_eq!(chars, 16 << 20);
|
||||
assert!(warned(&d, "text beyond the characters one document may hold"), "{:?}", d.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_paragraphs_and_runs_are_matched_in_about_linear_time() {
|
||||
// 200 000 paragraphs, each with its own run and format.
|
||||
let n = 200_000;
|
||||
let text = "a\u{2029}".repeat(n);
|
||||
let fmt =
|
||||
|end: usize| vec![(tag(b"Indx"), F::I32(end as i32)), (tag(b"Item"), F::Obj(tag(b"PAtt"), vec![(tag(b"Ints"), array_i32(b"Ints", &[1]))]))];
|
||||
let blk = F::Obj(
|
||||
tag(b"StBl"),
|
||||
vec![
|
||||
(tag(b"Glyp"), utf8(&text)),
|
||||
(tag(b"GAtt"), F::Obj(tag(b"GlAS"), vec![(tag(b"Runs"), F::Objs(tag(b"GlAR"), (1..=n).map(|i| run(2 * i as i32, None)).collect()))])),
|
||||
(tag(b"PAtt"), F::Obj(tag(b"PaAS"), vec![(tag(b"Runs"), F::Objs(tag(b"PaAR"), (1..=n).map(|i| fmt(2 * i)).collect()))])),
|
||||
],
|
||||
);
|
||||
let started = std::time::Instant::now();
|
||||
let d = open(vec![artistic(F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![blk]))]))]);
|
||||
let Kind::Text(t) = &d.spreads[0].nodes[0].kind else { panic!("text") };
|
||||
assert_eq!(t.runs.len(), n);
|
||||
// (About half a second; quadratic matching took over half a minute on the same machine.)
|
||||
assert!(started.elapsed().as_secs() < 10, "{:?}", started.elapsed());
|
||||
}
|
||||
|
||||
/// A shape node of `size` px.
|
||||
fn rect(size: f64) -> F {
|
||||
F::Obj(tag(b"ShpN"), vec![(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, size, size])), (tag(b"Shpe"), F::Obj(tag(b"ShNR"), vec![]))])
|
||||
}
|
||||
|
||||
/// An instance of the embedded document in archive entry `edc/1`, with `children` of its own.
|
||||
fn embedded(children: Vec<F>) -> F {
|
||||
let content = F::Obj(tag(b"EmbC"), vec![(tag(b"EmbC"), F::Entry("edc/1".into()))]);
|
||||
let mut fields = vec![(tag(b"Bitm"), F::Obj(tag(b"EmbR"), vec![(tag(b"EmCn"), content)]))];
|
||||
if !children.is_empty() {
|
||||
fields.push((tag(b"Chld"), F::Shared(children)));
|
||||
}
|
||||
F::Obj(tag(b"EmbN"), fields)
|
||||
}
|
||||
|
||||
fn embedding(inner: &[u8], nodes: Vec<F>) -> Vec<u8> {
|
||||
let mut entry = b"EmDc\0\0\0\0".to_vec();
|
||||
entry.extend_from_slice(inner);
|
||||
file(nodes, &[("edc/1", &entry)])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_embedded_documents_content_stays_with_its_node() {
|
||||
// Read before the node's own children, which must not take it.
|
||||
let inner = file(vec![rect(10.0), rect(20.0)], &[]);
|
||||
let d = read(&embedding(&inner, vec![embedded(vec![rect(5.0)])]), Limits::default()).unwrap();
|
||||
let node = &d.spreads[0].nodes[0];
|
||||
assert_eq!(node.children.len(), 3, "its own child and the embedded document's two shapes");
|
||||
assert!(node.children.iter().all(|c| c.children.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedding_one_document_many_times_shares_one_budget() {
|
||||
// 2000 shapes, embedded 300 times and read twice each: 1.2 million nodes, past the 500 000
|
||||
// a whole read may take (each embedding used to have an allowance of its own).
|
||||
let inner = file((0..2000).map(|_| rect(10.0)).collect(), &[]);
|
||||
let outer = embedding(&inner, (0..300).map(|_| embedded(Vec::new())).collect());
|
||||
assert!(matches!(read(&outer, Limits::default()), Err(Error::Limit(_))));
|
||||
// Embedded fewer times, it opens.
|
||||
let d = read(&embedding(&inner, (0..50).map(|_| embedded(Vec::new())).collect()), Limits::default()).unwrap();
|
||||
assert_eq!(all(&d.spreads[0].nodes).len(), 50 + 50 * 2000);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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! {
|
||||
@@ -247,9 +259,19 @@ fn excessive_feature_settings_are_bounded_and_reported() {
|
||||
assert!(warning(&d, "more than 256 text feature settings"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_names_longer_than_256_bytes_are_dropped() {
|
||||
// Each paragraph and run carries its font's name: a huge one would be copied with each.
|
||||
let d = read(vec![block(utf8("x"), vec![run(1, Some(attrs(&"F".repeat(257), "Inter-Regular", 400, false, 12.0)))], &[])]);
|
||||
let r = &text(&d).runs[0];
|
||||
assert!(r.family.is_empty() && r.postscript == "Inter-Regular");
|
||||
assert!(warning(&d, "font names longer than 256 bytes"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_font_metadata_amplification_is_bounded_without_losing_characters() {
|
||||
let mut a = attrs(&"F".repeat(16 * 1024), &"P".repeat(16 * 1024), 400, false, 12.0);
|
||||
// The longest font names kept (see `font_names_longer_than_256_bytes_are_dropped`).
|
||||
let mut a = attrs(&"F".repeat(256), &"P".repeat(256), 400, false, 12.0);
|
||||
if let F::Obj(_, fields) = &mut a {
|
||||
let settings = F::Obj(
|
||||
tag(b"OtAt"),
|
||||
@@ -266,11 +288,12 @@ fn shared_font_metadata_amplification_is_bounded_without_losing_characters() {
|
||||
}
|
||||
let F::Obj(class, fields) = a else { panic!("attributes must be an object") };
|
||||
let mut runs = vec![run(1, Some(F::Def(42, vec![class], fields)))];
|
||||
runs.extend((2..=1000).map(|end| run(end, Some(F::Ref(42)))));
|
||||
let d = read(vec![block(utf8(&"x".repeat(1000)), runs, &[])]);
|
||||
// About 7.7 kB of names and features per run: the budget runs out after about 2200 runs.
|
||||
runs.extend((2..=4000).map(|end| run(end, Some(F::Ref(42)))));
|
||||
let d = read(vec![block(utf8(&"x".repeat(4000)), runs, &[])]);
|
||||
let t = text(&d);
|
||||
assert_eq!(content(t), "x".repeat(1000));
|
||||
assert_eq!(t.runs.len(), 1000);
|
||||
assert_eq!(content(t), "x".repeat(4000));
|
||||
assert_eq!(t.runs.len(), 4000);
|
||||
let metadata: usize = t
|
||||
.runs
|
||||
.iter()
|
||||
@@ -279,7 +302,7 @@ fn shared_font_metadata_amplification_is_bounded_without_losing_characters() {
|
||||
.map(|s| s.len() + std::mem::size_of::<String>())
|
||||
.sum();
|
||||
assert!(metadata <= 16 << 20, "shared metadata was amplified to {metadata} bytes");
|
||||
assert_eq!(t.runs[0].family.len(), 16 * 1024);
|
||||
assert_eq!(t.runs[0].family.len(), 256);
|
||||
assert_eq!(t.runs[0].features.len(), 256);
|
||||
assert!(t.runs.last().unwrap().family.is_empty());
|
||||
assert!(t.runs.last().unwrap().features.is_empty());
|
||||
@@ -320,3 +343,39 @@ fn shared_gradient_attributes_are_bounded_without_losing_characters() {
|
||||
assert!(warning(&d, "text attribute budget exceeded"));
|
||||
assert!(!warning(&d, "text without character attributes"), "budget recovery must not claim source attributes are absent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_gradient_outlines_are_bounded_without_losing_characters() {
|
||||
let color =
|
||||
F::Def(43, vec![tag(b"RGBA")], vec![(tag(b"_col"), F::Struct([0.2f32, 0.4, 0.6, 1.0].iter().flat_map(|v| v.to_le_bytes()).collect()))]);
|
||||
let mut colors = vec![color];
|
||||
colors.extend((1..1024).map(|_| F::Ref(43)));
|
||||
let positions = (0..1024).map(|i| [(i as f32) / 1023.0, 0.5].iter().flat_map(|v| v.to_le_bytes()).collect()).collect();
|
||||
let pen = F::Obj(
|
||||
tag(b"FilG"),
|
||||
vec![(tag(b"Grad"), F::Obj(tag(b"Grad"), vec![(tag(b"Cols"), F::Shared(colors)), (tag(b"Posn"), F::Records(8, positions))]))],
|
||||
);
|
||||
let style = F::Obj(tag(b"LSty"), vec![(tag(b"Wght"), F::F64(2.0))]);
|
||||
// `Objs` 1 and 2: the outline's paint and line style, shared by every run.
|
||||
let attrs = F::Def(42, vec![tag(b"GAtt")], vec![(tag(b"Objs"), F::Shared(vec![solid(1.0), pen, style]))]);
|
||||
let mut runs = vec![run(1, Some(attrs))];
|
||||
runs.extend((2..=1000).map(|end| run(end, Some(F::Ref(42)))));
|
||||
let d = read(vec![block(utf8(&"x".repeat(1000)), runs, &[])]);
|
||||
let t = text(&d);
|
||||
assert_eq!(content(t), "x".repeat(1000));
|
||||
assert_eq!(t.runs.len(), 1000);
|
||||
let stop_count: usize = t
|
||||
.runs
|
||||
.iter()
|
||||
.filter_map(|r| r.stroke.as_ref())
|
||||
.map(|s| match &s.paint {
|
||||
Paint::Gradient(g) => g.stops.len(),
|
||||
_ => 0,
|
||||
})
|
||||
.sum();
|
||||
assert!(stop_count * std::mem::size_of::<vectorcraft_affinity::paint::Stop>() <= 16 << 20);
|
||||
let Some(Paint::Gradient(first)) = t.runs[0].stroke.as_ref().map(|s| &s.paint) else { panic!("the first outline must fit") };
|
||||
assert_eq!(first.stops.len(), 1024);
|
||||
assert!(t.runs.last().unwrap().stroke.is_none());
|
||||
assert!(warning(&d, "text attribute budget exceeded"));
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -192,8 +192,10 @@ pub(crate) fn effect_image(out: &mut Document, whole: &Node, knockout: Option<&N
|
||||
if !area.is_finite() {
|
||||
return None;
|
||||
}
|
||||
// Whole pixels, and no larger than 64 Mpx.
|
||||
let scale = scale.min((64.0e6 / area.max(1.0)).sqrt());
|
||||
// Whole pixels, no larger than 64 Mpx, and no side longer than the renderer can address
|
||||
// (a long thin object stays within the area but not within a side).
|
||||
let side = f64::from(vectorcraft_render::MAX_RASTER_SIDE) - 2.0;
|
||||
let scale = scale.min((64.0e6 / area.max(1.0)).sqrt()).min(side / b.width().max(b.height()).max(1.0));
|
||||
if !(scale.is_finite() && scale > 0.0) {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,707 @@
|
||||
//! Affinity → VectorCraft mapping of pages, master pages, tables, paragraph formats, text scale,
|
||||
//! layer effects and embedded documents, on original synthetic documents built with the reader's
|
||||
//! `synth` feature (no Affinity software or files were used). The fixtures follow DesignCraft's
|
||||
//! Affinity import tests, whose reader VectorCraft shares.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
#[path = "support/affinity_docs.rs"]
|
||||
mod affinity_docs;
|
||||
#[path = "../../affinity/tests/support/text_fixture.rs"]
|
||||
mod text_fixture;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use affinity_docs::*;
|
||||
|
||||
use kurbo::Rect;
|
||||
use text_fixture::{array_f64, array_i32, attrs, block, run, utf8};
|
||||
use vectorcraft_affinity::synth::{F, tag};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::appearance::{AppearanceItem, StrokeAlign};
|
||||
use vectorcraft_doc::text::{TextKind, TextObject, VerticalAlign};
|
||||
use vectorcraft_doc::{Document, Node, NodeKind};
|
||||
use vectorcraft_engine::cmd::fileio::{Loaded, load};
|
||||
|
||||
fn near(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-6
|
||||
}
|
||||
|
||||
fn open(name: &str, bytes: &[u8]) -> Loaded {
|
||||
let loaded = load(name, bytes).unwrap();
|
||||
assert!(!loaded.preview_only, "{:?}", loaded.warnings);
|
||||
loaded
|
||||
}
|
||||
|
||||
/// Every node of the document, depth first.
|
||||
fn all(d: &Document) -> Vec<&Node> {
|
||||
fn walk<'a>(nodes: &'a [Arc<Node>], out: &mut Vec<&'a Node>) {
|
||||
for n in nodes {
|
||||
out.push(n);
|
||||
if let Some(c) = n.children() {
|
||||
walk(c, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
walk(&d.layers, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn named<'a>(d: &'a Document, name: &str) -> &'a Node {
|
||||
all(d).into_iter().find(|n| n.name.as_deref() == Some(name)).unwrap_or_else(|| panic!("no node {name}"))
|
||||
}
|
||||
|
||||
fn texts(d: &Document) -> Vec<&TextObject> {
|
||||
all(d).into_iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.as_ref()) } else { None }).collect()
|
||||
}
|
||||
|
||||
fn content(t: &TextObject) -> String {
|
||||
t.runs.iter().map(|r| r.text.as_str()).collect()
|
||||
}
|
||||
|
||||
/// An area text's frame in document space.
|
||||
fn frame(t: &TextObject) -> Rect {
|
||||
let TextKind::Area { frame } = &t.kind else { panic!("area type: {:?}", t.kind) };
|
||||
t.xf.transform_rect_bbox(frame.bounds().unwrap())
|
||||
}
|
||||
|
||||
fn rect_near(a: Rect, b: Rect) -> bool {
|
||||
near(a.x0, b.x0) && near(a.y0, b.y0) && near(a.x1, b.x1) && near(a.y1, b.y1)
|
||||
}
|
||||
|
||||
fn layer_names(d: &Document) -> Vec<&str> {
|
||||
d.layers.iter().map(|l| l.name.as_deref().unwrap_or("")).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publisher_pages_become_artboards_in_points() {
|
||||
let d = open("brochure.afpub", &publisher()).doc;
|
||||
let boards: Vec<_> = d.artboards.iter().map(|b| (b.name.as_str(), b.rect)).collect();
|
||||
assert_eq!(boards.len(), 2);
|
||||
assert_eq!((boards[0].0, boards[1].0), ("Page 1", "Page 2"));
|
||||
assert!(rect_near(boards[0].1, Rect::new(0.0, 0.0, 2480.0 * PT, 3508.0 * PT)), "{boards:?}");
|
||||
assert!(rect_near(boards[1].1, Rect::new(2480.0 * PT, 0.0, 4960.0 * PT, 3508.0 * PT)), "{boards:?}");
|
||||
assert!(layer_names(&d).contains(&"Background"));
|
||||
let rect = named(&d, "Magenta box");
|
||||
assert!(rect_near(rect.geometric_bounds().unwrap(), Rect::new(100.0 * PT, 100.0 * PT, 200.0 * PT, 300.0 * PT)));
|
||||
let Some(AppearanceItem::Fill(fill)) = rect.appearance.items.first() else { panic!("{:?}", rect.appearance) };
|
||||
assert_eq!(fill.paint, Paint::solid(Color::cmyk(0.0, 1.0, 0.0, 0.0)), "CMYK stays CMYK");
|
||||
}
|
||||
|
||||
/// Several spreads of several pages: every page is an artboard, the spreads side by side.
|
||||
#[test]
|
||||
fn every_page_of_every_spread_is_an_artboard() {
|
||||
let file = publisher_with(vec![
|
||||
spread(2, 1, vec![rectangle(10, (10.0, 10.0), (20.0, 20.0))]),
|
||||
spread(3, 2, vec![rectangle(20, (2500.0, 10.0), (20.0, 20.0))]),
|
||||
spread(4, 2, vec![]),
|
||||
]);
|
||||
let d = open("book.afpub", &file).doc;
|
||||
let names: Vec<_> = d.artboards.iter().map(|b| b.name.as_str()).collect();
|
||||
assert_eq!(names, ["Page 1", "Page 2", "Page 3", "Page 4", "Page 5"]);
|
||||
assert_eq!(d.artboards.iter().map(|b| b.id).collect::<Vec<_>>(), [1, 2, 3, 4, 5]);
|
||||
let (w, h) = (2480.0 * PT, 3508.0 * PT);
|
||||
for b in &d.artboards {
|
||||
assert!(near(b.rect.width(), w) && near(b.rect.height(), h), "{b:?}");
|
||||
}
|
||||
// The two pages of a spread touch; spreads are 36 pt apart.
|
||||
let x: Vec<f64> = d.artboards.iter().map(|b| b.rect.x0).collect();
|
||||
assert!(near(x[1], w + 36.0) && near(x[2], 2.0 * w + 36.0) && near(x[3], 3.0 * w + 72.0), "{x:?}");
|
||||
// The second spread's box is on its second page.
|
||||
let boxes: Vec<Rect> = all(&d).into_iter().filter(|n| n.name.as_deref() == Some("Magenta box")).map(|n| n.geometric_bounds().unwrap()).collect();
|
||||
assert_eq!(boxes.len(), 2);
|
||||
assert!(d.artboards[2].rect.contains(boxes[1].center()), "{boxes:?} {:?}", d.artboards[2]);
|
||||
let reopened = vectorcraft_format::load_file(&vectorcraft_format::save_file(&d)).unwrap();
|
||||
assert_eq!(reopened.doc.artboards, d.artboards);
|
||||
}
|
||||
|
||||
/// Artboards on one canvas keep their names, sizes, places and own content; objects beside them
|
||||
/// stay on the canvas.
|
||||
#[test]
|
||||
fn several_artboards_keep_their_own_content() {
|
||||
let file = canvas_with(
|
||||
vec![
|
||||
board(10, "Icon", (0.0, 0.0), (64.0, 64.0), vec![named_rect(11, "Icon art", (8.0, 8.0), (48.0, 48.0))]),
|
||||
named_rect(20, "Loose", (80.0, 0.0), (10.0, 10.0)),
|
||||
board(30, "Banner", (100.0, 0.0), (300.0, 100.0), vec![named_rect(31, "Banner art", (110.0, 10.0), (280.0, 80.0))]),
|
||||
board(40, "Card", (0.0, 200.0), (85.0, 55.0), vec![]),
|
||||
],
|
||||
None,
|
||||
);
|
||||
let d = open("set.af", &file).doc;
|
||||
let boards: Vec<_> = d.artboards.iter().map(|b| (b.name.as_str(), b.rect)).collect();
|
||||
assert_eq!(
|
||||
boards,
|
||||
[("Icon", Rect::new(0.0, 0.0, 64.0, 64.0)), ("Banner", Rect::new(100.0, 0.0, 400.0, 100.0)), ("Card", Rect::new(0.0, 200.0, 85.0, 255.0))]
|
||||
);
|
||||
// Each artboard is a layer holding its clip group, its art inside.
|
||||
for (board, art) in [("Icon", "Icon art"), ("Banner", "Banner art")] {
|
||||
let layer = d.layers.iter().find(|l| l.name.as_deref() == Some(board)).unwrap();
|
||||
let group = &layer.children().unwrap()[0];
|
||||
assert!(matches!(group.kind, NodeKind::Group { clip: true, .. }));
|
||||
assert!(group.children().unwrap().iter().any(|c| c.name.as_deref() == Some(art)), "{board}");
|
||||
}
|
||||
assert!(rect_near(named(&d, "Loose").geometric_bounds().unwrap(), Rect::new(80.0, 0.0, 90.0, 10.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artistic_text_keeps_its_paragraphs_size_and_leading() {
|
||||
let d = open("brochure.afpub", &publisher()).doc;
|
||||
let t = texts(&d)[0];
|
||||
assert_eq!(content(t), "Hello\nWorld", "paragraph marks become paragraphs; the final one goes");
|
||||
assert!(near(t.runs[0].style.size, 100.0 * PT));
|
||||
assert_eq!(t.runs[0].style.font_style, "Bold");
|
||||
assert_eq!(t.para.justify, vectorcraft_doc::text::Justify::Center);
|
||||
assert!(matches!(t.kind, TextKind::Point));
|
||||
// Centred on the anchor (x = 1000 + 200), first baseline 80 px below the frame top (y = 500).
|
||||
let origin = t.xf * kurbo::Point::ZERO;
|
||||
assert!(near(origin.x, 1200.0 * PT) && near(origin.y, 580.0 * PT), "{origin:?}");
|
||||
// Lines are the font size plus the font's line gap apart (VectorCraft's default is 120 %).
|
||||
let face = vectorcraft_text::FontDb::global().face(&t.runs[0].style.font_family, &t.runs[0].style.font_style).unwrap();
|
||||
let expected = 100.0 * (1.0 + face.line_gap() / face.units_per_em());
|
||||
assert!(near(t.para.justification.auto_leading, expected), "{} vs {expected}", t.para.justification.auto_leading);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tables_become_cell_text_and_edge_lines() {
|
||||
// Stretched to twice its width by its transform: the grid follows, the type doesn't.
|
||||
let loaded = open("table.afpub", &page_with(table_node(30, [2.0, 0.0, 400.0, 0.0, 1.0, 600.0])));
|
||||
let d = &loaded.doc;
|
||||
let table = named(d, "Table");
|
||||
let cells: Vec<&TextObject> =
|
||||
table.children().unwrap().iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.as_ref()) } else { None }).collect();
|
||||
assert_eq!(cells.iter().map(|t| content(t)).collect::<Vec<_>>(), ["A", "B", "C", "D", "E"]);
|
||||
let a = cells[0];
|
||||
assert!(near(a.runs[0].style.size, 40.0 * PT), "type keeps its size");
|
||||
assert!(near(a.runs[0].style.h_scale, 100.0), "type isn't stretched with the grid");
|
||||
assert_eq!(a.area.vertical_align, VerticalAlign::Center);
|
||||
// Insets (5, 6, 7, 8 px) inside the cell, scaled with the grid; the text centred in them as
|
||||
// Affinity centres it.
|
||||
let f = frame(a);
|
||||
assert!(near(f.x0, (400.0 + 10.0) * PT) && near(f.width(), 176.0 * PT), "{f:?}");
|
||||
let (top, last) = cap_to_last_baseline(a);
|
||||
assert!(((top + last) / 2.0 - (600.0 + 24.0) * PT).abs() < 1e-6, "{top} {last}");
|
||||
// The merged cell spans two columns.
|
||||
assert!(near(frame(cells[3]).width(), (400.0 - 24.0) * PT), "{:?}", frame(cells[3]));
|
||||
// 8 vertical edges less the one inside the merged cell, and 9 horizontal ones.
|
||||
let lines: Vec<&Arc<Node>> = table.children().unwrap().iter().filter(|n| matches!(n.kind, NodeKind::Path { .. })).collect();
|
||||
assert_eq!(lines.len(), 16);
|
||||
let Some(AppearanceItem::Stroke(s)) = lines[0].appearance.items.first() else { panic!() };
|
||||
assert_eq!(s.paint, Paint::solid(Color::cmyk(1.0, 0.5, 0.0, 0.0)));
|
||||
assert!(s.width > 0.0);
|
||||
}
|
||||
|
||||
/// Where a cell's text goes in the document: its first line's capitals' tops and its last
|
||||
/// baseline (y).
|
||||
fn cap_to_last_baseline(t: &TextObject) -> (f64, f64) {
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
// Hung from the frame's top by its cap height, the first baseline is that far down.
|
||||
let mut top = t.clone();
|
||||
top.area.vertical_align = VerticalAlign::Top;
|
||||
let cap = vectorcraft_text::layout(db, &top).lines[0].baseline;
|
||||
let lines = vectorcraft_text::layout(db, t).lines;
|
||||
let y = t.xf.translation().y;
|
||||
(y + lines[0].baseline - cap, y + lines.last().unwrap().baseline)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_text_taller_than_its_cell_is_centred_on_it_and_shows() {
|
||||
// "A" over "a" (a line break inside the paragraph) in a cell 36 px tall inside its insets.
|
||||
let loaded = open("cells.afpub", &page_with(table_with(30, [1.0, 0.0, 400.0, 0.0, 1.0, 600.0], ["A\u{2028}a", "B", "C", "D", "E"])));
|
||||
assert!(!loaded.warnings.iter().any(|w| w.contains("don't fit")), "{:?}", loaded.warnings);
|
||||
let a = texts(&loaded.doc)[0];
|
||||
assert_eq!(content(a), "A\na");
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), a);
|
||||
assert_eq!(lay.lines.len(), 2, "both lines show");
|
||||
assert!(!lay.overflow);
|
||||
assert!(frame(a).height() > 36.0 * PT, "the frame grew: {:?}", frame(a));
|
||||
let (top, last) = cap_to_last_baseline(a);
|
||||
assert!(top < (600.0 + 6.0) * PT, "reaching out of the cell");
|
||||
assert!(((top + last) / 2.0 - (600.0 + 24.0) * PT).abs() < 1e-6, "centred: {top} {last}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_breaks_inside_a_paragraph_get_no_paragraph_spacing() {
|
||||
// Doub slots: 4 first line, 5 space before, 6 after (see the test below).
|
||||
let attrs_para = F::Obj(tag(b"PAtt"), vec![(tag(b"Doub"), array_f64(b"Doub", &[1.0, 0.0, 0.0, 0.0, 25.0, 9.0, 17.0]))]);
|
||||
let blk = F::Obj(
|
||||
tag(b"StBl"),
|
||||
vec![
|
||||
(tag(b"Glyp"), utf8("One\u{2028}two\u{2029}Three\u{2029}")),
|
||||
(
|
||||
tag(b"GAtt"),
|
||||
F::Obj(
|
||||
tag(b"GlAS"),
|
||||
vec![(tag(b"Runs"), F::Objs(tag(b"GlAR"), vec![run(14, Some(attrs("Inter", "Inter-Regular", 400, false, 30.0)))]))],
|
||||
),
|
||||
),
|
||||
(
|
||||
tag(b"PAtt"),
|
||||
F::Obj(tag(b"PaAS"), vec![(tag(b"Runs"), F::Objs(tag(b"PaAR"), vec![vec![(tag(b"Indx"), F::I32(14)), (tag(b"Item"), attrs_para)]]))]),
|
||||
),
|
||||
],
|
||||
);
|
||||
let node = F::Def(
|
||||
40,
|
||||
vec![tag(b"TxtF")],
|
||||
vec![
|
||||
(tag(b"StSt"), F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![blk]))])),
|
||||
(tag(b"TxtH"), F::Obj(tag(b"FrFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 500.0, 300.0]))])),
|
||||
],
|
||||
);
|
||||
let t = texts(&open("breaks.afpub", &page_with(node)).doc)[0].clone();
|
||||
assert_eq!(content(&t), "One\ntwo\nThree");
|
||||
let p = &t.paras;
|
||||
let spacing: Vec<(f64, f64, f64)> = p.iter().map(|p| (p.space_before / PT, p.space_after / PT, p.first_line_indent / PT)).collect();
|
||||
let r = |v: (f64, f64, f64)| ((v.0 * 1e6).round() / 1e6, (v.1 * 1e6).round() / 1e6, (v.2 * 1e6).round() / 1e6);
|
||||
assert_eq!(
|
||||
spacing.into_iter().map(r).collect::<Vec<_>>(),
|
||||
[(9.0, 0.0, 25.0), (0.0, 17.0, 0.0), (0.0, 17.0, 25.0)],
|
||||
"space before the paragraph and after it, the first-line indent on its first line"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_paragraph_keeps_its_alignment_indents_and_spacing() {
|
||||
// Doub slots: 1 leading (Ints slot 1 = 2), 2 left, 3 right, 4 first line, 5 space before, 6 after.
|
||||
let para = |end: i32, align: i32| {
|
||||
vec![
|
||||
(tag(b"Indx"), F::I32(end)),
|
||||
(
|
||||
tag(b"Item"),
|
||||
F::Obj(
|
||||
tag(b"PAtt"),
|
||||
vec![
|
||||
(tag(b"Doub"), array_f64(b"Doub", &[1.0, 5.0, 40.0, 3.0, 25.0, 9.0, 17.0])),
|
||||
(tag(b"Ints"), array_i32(b"Ints", &[align, 2])),
|
||||
],
|
||||
),
|
||||
),
|
||||
]
|
||||
};
|
||||
let blk = F::Obj(
|
||||
tag(b"StBl"),
|
||||
vec![
|
||||
(tag(b"Glyp"), utf8("One\u{2029}Two\u{2029}")),
|
||||
(
|
||||
tag(b"GAtt"),
|
||||
F::Obj(
|
||||
tag(b"GlAS"),
|
||||
vec![(tag(b"Runs"), F::Objs(tag(b"GlAR"), vec![run(8, Some(auto_leading(attrs("Inter", "Inter-Regular", 400, false, 30.0))))]))],
|
||||
),
|
||||
),
|
||||
(tag(b"PAtt"), F::Obj(tag(b"PaAS"), vec![(tag(b"Runs"), F::Objs(tag(b"PaAR"), vec![para(4, 0), para(8, 2)]))])),
|
||||
],
|
||||
);
|
||||
let node = F::Def(
|
||||
40,
|
||||
vec![tag(b"TxtF")],
|
||||
vec![
|
||||
(tag(b"StSt"), F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![blk]))])),
|
||||
(tag(b"TxtH"), F::Obj(tag(b"FrFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 500.0, 300.0]))])),
|
||||
],
|
||||
);
|
||||
let loaded = open("paragraphs.afpub", &page_with(node));
|
||||
assert!(!loaded.warnings.iter().any(|w| w.contains("paragraph")), "{:?}", loaded.warnings);
|
||||
let t = texts(&loaded.doc)[0];
|
||||
assert_eq!(content(t), "One\nTwo");
|
||||
assert_eq!(t.paras.len(), 2, "{:?}", t.paras);
|
||||
let p = &t.paras;
|
||||
use vectorcraft_doc::text::Justify;
|
||||
assert_eq!((p[0].justify, p[1].justify), (Justify::Left, Justify::Right));
|
||||
assert!(near(p[0].left_indent, 40.0 * PT) && near(p[0].first_line_indent, -15.0 * PT) && near(p[0].right_indent, 3.0 * PT), "{:?}", p[0]);
|
||||
assert!(near(p[0].space_before, 9.0 * PT) && near(p[0].space_after, 17.0 * PT));
|
||||
// The larger of 17 after "One" and 9 before "Two": nothing more before "Two".
|
||||
assert!(near(p[1].space_before, 0.0) && near(p[1].space_after, 17.0 * PT));
|
||||
assert!(t.runs.iter().all(|r| r.style.leading.is_some_and(|l| near(l, 5.0 * PT))), "the paragraph's fixed leading");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_type_follows_the_frame_text_scale_not_the_frame_transform() {
|
||||
// The same frame scaled 3× by its transform, without and with a text scale (FTxS) of 3.
|
||||
let frame_node = |ftxs: bool| {
|
||||
let story = block(utf8("Hi"), vec![run(2, Some(attrs("Inter", "Inter-Regular", 400, false, 10.0)))], &[]);
|
||||
let mut fields = vec![
|
||||
(tag(b"StSt"), F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![story]))])),
|
||||
(tag(b"TxtH"), F::Obj(tag(b"FrFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 100.0, 50.0]))])),
|
||||
(tag(b"Xfrm"), F::F64s(vec![3.0, 0.0, 0.0, 0.0, 3.0, 0.0])),
|
||||
];
|
||||
if ftxs {
|
||||
fields.push((tag(b"FTxS"), F::F64s(vec![3.0, 0.0, 0.0, 0.0, 3.0, 0.0])));
|
||||
}
|
||||
F::Def(40, vec![tag(b"TxtF")], fields)
|
||||
};
|
||||
for (ftxs, size) in [(false, 10.0), (true, 30.0)] {
|
||||
let d = open("frame.afpub", &page_with(frame_node(ftxs))).doc;
|
||||
let t = texts(&d)[0];
|
||||
assert!(near(frame(t).width(), 300.0 * PT), "the frame scales either way: {:?}", frame(t));
|
||||
assert!(near(t.runs[0].style.size, size * PT), "FTxS {ftxs}: {}", t.runs[0].style.size);
|
||||
assert!(near(t.xf.determinant(), 1.0), "the type isn't scaled by the placement");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_affinity_documents_come_in_as_their_content() {
|
||||
let inner = canvas_with(vec![rectangle(10, (0.0, 0.0), (100.0, 50.0))], None);
|
||||
let outer = canvas_with(vec![embedded_node(20)], Some(&inner));
|
||||
let loaded = open("logo.af", &outer);
|
||||
let rect = named(&loaded.doc, "Magenta box");
|
||||
// Content bounds (0,0)-(100,50), centre (50,25) → origin, then 2× at (500,300).
|
||||
assert!(rect_near(rect.geometric_bounds().unwrap(), Rect::new(400.0, 250.0, 600.0, 350.0)), "{:?}", rect.geometric_bounds());
|
||||
assert!(!loaded.warnings.iter().any(|w| w.contains("cached picture")), "{:?}", loaded.warnings);
|
||||
}
|
||||
|
||||
/// An embedded document's artboard frames its content but is no artboard of the document it is in.
|
||||
#[test]
|
||||
fn artboards_of_embedded_documents_are_not_artboards_here() {
|
||||
let inner = canvas_with(vec![board(10, "Logo board", (0.0, 0.0), (100.0, 50.0), vec![rectangle(11, (10.0, 10.0), (80.0, 30.0))])], None);
|
||||
let outer = canvas_with(vec![board(30, "Poster", (0.0, 0.0), (1000.0, 800.0), vec![embedded_node(20)])], Some(&inner));
|
||||
let d = open("poster.af", &outer).doc;
|
||||
assert_eq!(d.artboards.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(), ["Poster"]);
|
||||
// The logo still comes in, clipped by its own board.
|
||||
let logo = named(&d, "Logo board");
|
||||
assert!(matches!(logo.kind, NodeKind::Group { clip: true, .. }));
|
||||
assert!(rect_near(named(&d, "Magenta box").geometric_bounds().unwrap(), Rect::new(420.0, 270.0, 580.0, 330.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_documents_nest_only_so_deep() {
|
||||
let mut doc = canvas_with(vec![rectangle(10, (0.0, 0.0), (100.0, 50.0))], None);
|
||||
for _ in 0..6 {
|
||||
doc = canvas_with(vec![embedded_node(20)], Some(&doc));
|
||||
}
|
||||
let loaded = open("deep.af", &doc);
|
||||
assert!(loaded.warnings.iter().any(|w| w.contains("nested more than four deep")), "{:?}", loaded.warnings);
|
||||
}
|
||||
|
||||
/// An embedded document's master pages aren't kept, so its instances of them are left out: in the
|
||||
/// document it is embedded in they would show that document's masters.
|
||||
#[test]
|
||||
fn master_pages_of_embedded_documents_are_left_out() {
|
||||
use vectorcraft_affinity::model::{Kind, Node as AfNode};
|
||||
fn instances(nodes: &[AfNode]) -> usize {
|
||||
nodes.iter().map(|n| usize::from(matches!(n.kind, Kind::MasterInstance { .. })) + instances(&n.children)).sum()
|
||||
}
|
||||
let outer = canvas_with(vec![embedded_node(20)], Some(&with_master(1)));
|
||||
let d = vectorcraft_affinity::read(&outer, vectorcraft_affinity::Limits::default()).unwrap();
|
||||
assert_eq!(instances(&d.spreads[0].nodes), 0);
|
||||
assert!(d.warnings.iter().any(|w| w.contains("master pages of embedded documents")), "{:?}", d.warnings);
|
||||
// Its own objects still come in.
|
||||
let loaded = open("poster.af", &outer);
|
||||
named(&loaded.doc, "Page box");
|
||||
}
|
||||
|
||||
/// A dash pattern has a few lengths; a crafted one of thousands would be copied with every stroke.
|
||||
#[test]
|
||||
fn dash_patterns_of_more_than_64_lengths_import_as_solid_lines() {
|
||||
use vectorcraft_affinity::model::Kind;
|
||||
for (lengths, dashed) in [(64, true), (65, false)] {
|
||||
let dash: Vec<f64> = (0..lengths).map(|i| 1.0 + f64::from(i)).collect();
|
||||
let d = vectorcraft_affinity::read(&canvas_with(vec![stroked(10, 2, &dash)], None), vectorcraft_affinity::Limits::default()).unwrap();
|
||||
let Kind::Shape { strokes, .. } = &d.spreads[0].nodes[0].kind else { panic!("{:?}", d.spreads[0].nodes[0].kind) };
|
||||
assert_eq!(strokes[0].dash.as_ref().map(|(d, _)| d.len()), dashed.then_some(64));
|
||||
assert_eq!(d.warnings.iter().any(|w| w.contains("dash patterns of more than 64")), !dashed, "{:?}", d.warnings);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn master_pages_show_on_the_pages_that_use_them() {
|
||||
let loaded = open("master.afpub", &with_master(1));
|
||||
let d = &loaded.doc;
|
||||
assert!(!loaded.warnings.iter().any(|w| w.contains("master")), "{:?}", loaded.warnings);
|
||||
assert_eq!(d.artboards.len(), 1, "a master spread is no artboard");
|
||||
// Behind the page's own objects, on a locked layer of its own.
|
||||
let master = &d.layers[0];
|
||||
assert_eq!(master.name.as_deref(), Some("Master A"));
|
||||
assert!(master.locked);
|
||||
// Only the second master page's box, moved onto the page.
|
||||
assert!(all(d).iter().all(|n| n.name.as_deref() != Some("Left master box")));
|
||||
let b = named(d, "Right master box").geometric_bounds().unwrap();
|
||||
assert!(rect_near(b, Rect::new(10.0, 10.0, 40.0, 50.0)), "{b:?}");
|
||||
assert!(named(d, "Page box").geometric_bounds().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_master_page_shows_on_every_page_it_covers() {
|
||||
// Two pages show master pages 2 and 3 (the master spread has two: the last repeats).
|
||||
let d = open("master2.afpub", &with_master(2)).doc;
|
||||
assert_eq!(d.artboards.len(), 2);
|
||||
let boxes: Vec<Rect> =
|
||||
all(&d).into_iter().filter(|n| n.name.as_deref() == Some("Right master box")).map(|n| n.geometric_bounds().unwrap()).collect();
|
||||
assert_eq!(boxes.len(), 2, "{boxes:?}");
|
||||
for (b, board) in boxes.iter().zip(&d.artboards) {
|
||||
assert!(board.rect.contains(b.center()), "{b:?} on {:?}", board.rect);
|
||||
}
|
||||
}
|
||||
|
||||
fn pixel(d: &Document, x: usize, y: usize) -> [u8; 4] {
|
||||
let img = vectorcraft_render::Renderer::new().render_region(d, d.artboards[0].rect, 1.0, false);
|
||||
img.to_straight()[(y * img.width as usize + x) * 4..][..4].try_into().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadows_go_where_affinity_puts_them() {
|
||||
// A blue shadow 10 px straight below (angle π/2), blurred 2 px, at half opacity.
|
||||
let shadow = F::Obj(
|
||||
tag(b"Shad"),
|
||||
vec![
|
||||
(tag(b"Enab"), F::Bool(true)),
|
||||
(tag(b"Opac"), F::F64(0.5)),
|
||||
(tag(b"Radi"), F::F64(2.0)),
|
||||
(tag(b"Offs"), F::F64(10.0)),
|
||||
(tag(b"Angl"), F::F64(std::f64::consts::FRAC_PI_2)),
|
||||
(tag(b"Colr"), rgba(0.0, 0.0, 1.0)),
|
||||
],
|
||||
);
|
||||
let bevel = F::Obj(tag(b"BevE"), vec![(tag(b"Enab"), F::Bool(true))]);
|
||||
let loaded = open("shadow.af", &canvas_with(vec![with_effects(rectangle(10, (0.0, 0.0), (50.0, 50.0)), vec![shadow, bevel])], None));
|
||||
let d = &loaded.doc;
|
||||
let rect = named(d, "Magenta box");
|
||||
let fx = &rect.appearance.effects;
|
||||
assert_eq!(fx.len(), 1, "{fx:?}");
|
||||
assert_eq!(fx[0].id, "stylize.dropShadow");
|
||||
let p = &fx[0].params;
|
||||
assert!(near(p["x"].as_f64().unwrap(), 0.0) && near(p["y"].as_f64().unwrap(), 10.0), "{p}");
|
||||
assert_eq!((p["blur"].as_f64(), p["opacity"].as_f64()), (Some(2.0), Some(50.0)));
|
||||
// A bevel without a radius draws nothing, in Affinity as here.
|
||||
assert!(!loaded.warnings.iter().any(|w| w.contains("bevel")), "{:?}", loaded.warnings);
|
||||
// Drawn: blue at half strength below the box, the box itself magenta.
|
||||
let [r, g, b, a] = pixel(d, 25, 57);
|
||||
assert!(r < 10 && g < 10 && b > 245 && (100..160).contains(&a), "{:?}", [r, g, b, a]);
|
||||
assert_eq!(pixel(d, 25, 25)[3], 255);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glows_inner_shadows_and_bevels_are_kept_or_reported() {
|
||||
let effect = |class: &[u8; 4], radius: f64| {
|
||||
F::Obj(
|
||||
tag(class),
|
||||
vec![(tag(b"Enab"), F::Bool(true)), (tag(b"Opac"), F::F64(0.8)), (tag(b"Radi"), F::F64(radius)), (tag(b"Colr"), rgba(1.0, 1.0, 0.0))],
|
||||
)
|
||||
};
|
||||
let node = with_effects(rectangle(10, (0.0, 0.0), (50.0, 50.0)), vec![effect(b"OutG", 6.0), effect(b"InnS", 3.0), effect(b"BevE", 4.0)]);
|
||||
let loaded = open("glow.af", &canvas_with(vec![node], None));
|
||||
let fx = &named(&loaded.doc, "Magenta box").appearance.effects;
|
||||
assert_eq!(fx.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["stylize.outerGlow", "stylize.innerGlow"]);
|
||||
assert_eq!((fx[0].params["blur"].as_f64(), fx[0].params["opacity"].as_f64()), (Some(6.0), Some(80.0)));
|
||||
for w in ["inner shadows (imported as inner glows", "bevel and emboss effects (left out)"] {
|
||||
assert!(loaded.warnings.iter().any(|x| x.contains(w)), "{w}: {:?}", loaded.warnings);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_effects_become_strokes() {
|
||||
let outline = |align: u16| {
|
||||
F::Obj(
|
||||
tag(b"Strk"),
|
||||
vec![(tag(b"Enab"), F::Bool(true)), (tag(b"Radi"), F::F64(3.0)), (tag(b"Alig"), F::Enum(align, 0)), (tag(b"Colr"), rgba(1.0, 0.0, 0.0))],
|
||||
)
|
||||
};
|
||||
let shape = with_effects(rectangle(10, (0.0, 0.0), (50.0, 50.0)), vec![outline(0)]);
|
||||
let text = with_effects(artistic_text(20, "Hi", 40.0), vec![outline(2)]);
|
||||
let d = open("outline.af", &canvas_with(vec![shape, text], None)).doc;
|
||||
let rect = named(&d, "Magenta box");
|
||||
let Some(AppearanceItem::Stroke(s)) = rect.appearance.items.iter().find(|i| matches!(i, AppearanceItem::Stroke(_))) else { panic!() };
|
||||
assert_eq!((s.width, s.align), (3.0, StrokeAlign::Outside));
|
||||
assert_eq!(s.paint, Paint::solid(Color::rgb(1.0, 0.0, 0.0)));
|
||||
let t = texts(&d)[0];
|
||||
assert_eq!((t.runs[0].style.stroke.clone(), t.runs[0].style.stroke_width), (Paint::solid(Color::rgb(1.0, 0.0, 0.0)), 3.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colour_overlays_are_baked_into_the_paint() {
|
||||
let overlay = F::Obj(tag(b"ColO"), vec![(tag(b"Enab"), F::Bool(true)), (tag(b"Opac"), F::F64(1.0)), (tag(b"Colr"), rgba(0.0, 1.0, 0.0))]);
|
||||
let d = open("overlay.af", &canvas_with(vec![with_effects(rectangle(10, (0.0, 0.0), (50.0, 50.0)), vec![overlay])], None)).doc;
|
||||
let rect = named(&d, "Magenta box");
|
||||
assert!(rect.appearance.effects.is_empty());
|
||||
let [r, g, b, a] = pixel(&d, 25, 25);
|
||||
assert!(r < 5 && g > 250 && b < 5 && a == 255, "{:?}", [r, g, b, a]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_effects_on_stroked_shapes_go_around_their_stroke() {
|
||||
let outline = |align: u16, width: f64| {
|
||||
F::Obj(
|
||||
tag(b"Strk"),
|
||||
vec![
|
||||
(tag(b"Enab"), F::Bool(true)),
|
||||
(tag(b"Radi"), F::F64(width)),
|
||||
(tag(b"Alig"), F::Enum(align, 0)),
|
||||
(tag(b"Colr"), rgba(1.0, 0.0, 0.0)),
|
||||
],
|
||||
)
|
||||
};
|
||||
// A black stroke 2 px wide, centred on the path: it reaches 1 px out of the shape.
|
||||
let stroked = |id: u32| {
|
||||
let mut node = rectangle(id, (0.0, 0.0), (50.0, 50.0));
|
||||
if let F::Def(_, _, fields) = &mut node {
|
||||
fields.push((tag(b"PFil"), cmyk(0.0, 0.0, 0.0, 1.0)));
|
||||
fields.push((tag(b"LSty"), F::Obj(tag(b"LSty"), vec![(tag(b"Wght"), F::F64(2.0))])));
|
||||
}
|
||||
node
|
||||
};
|
||||
let loaded = open("outline.af", &canvas_with(vec![with_effects(stroked(10), vec![outline(2, 4.0), outline(0, 3.0)])], None));
|
||||
assert!(!loaded.warnings.iter().any(|w| w.contains("outline")), "{:?}", loaded.warnings);
|
||||
let strokes: Vec<(f64, StrokeAlign, Paint)> = named(&loaded.doc, "Magenta box")
|
||||
.appearance
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|i| if let AppearanceItem::Stroke(s) = i { Some((s.width, s.align, s.paint.clone())) } else { None })
|
||||
.collect();
|
||||
let (red, black) = (Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::cmyk(0.0, 0.0, 0.0, 1.0)));
|
||||
assert_eq!(
|
||||
strokes,
|
||||
[
|
||||
// Outside: from the path to 3 px past the stroke, under it.
|
||||
(4.0, StrokeAlign::Outside, red.clone()),
|
||||
(2.0, StrokeAlign::Center, black),
|
||||
// Centred on the stroke's outer edge, over it (as far in as out).
|
||||
(6.0, StrokeAlign::Center, red),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artistic_text_aligns_its_lines_within_the_longest() {
|
||||
// (first, second alignment) → where "Mid" goes in the span of "Wide line" (0 its left end,
|
||||
// 0.5 centred, 1 its right end).
|
||||
for (first, second, at) in [(0, 1, 0.5), (0, 2, 1.0), (1, 0, 0.0), (2, 1, 0.5), (1, 2, 1.0)] {
|
||||
let d = open("mixed.af", &canvas_with(vec![mixed_alignment(first, second)], None)).doc;
|
||||
let t = texts(&d)[0];
|
||||
assert!(matches!(t.kind, TextKind::Point));
|
||||
let lines = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).lines;
|
||||
let (wide, mid) = (&lines[0], &lines[1]);
|
||||
let w = wide.x1 - wide.x0;
|
||||
assert!(w > 0.0);
|
||||
// The block stays where the first paragraph's alignment puts it on the anchor.
|
||||
let start = [0.0, -0.5, -1.0][first as usize] * w;
|
||||
assert!((wide.x0 - start).abs() < 1e-6, "{first}/{second}: {} vs {start}", wide.x0);
|
||||
let expected = wide.x0 + (w - (mid.x1 - mid.x0)) * at;
|
||||
assert!((mid.x0 - expected).abs() < 1e-6, "{first}/{second}: {} vs {expected}", mid.x0);
|
||||
// Each paragraph keeps its own alignment.
|
||||
use vectorcraft_doc::text::Justify;
|
||||
let j = |a: i32| [Justify::Left, Justify::Center, Justify::Right][a as usize];
|
||||
assert_eq!((t.paras[0].justify, t.paras[1].justify), (j(first), j(second)));
|
||||
}
|
||||
// One alignment throughout: no indents.
|
||||
let d = open("same.af", &canvas_with(vec![mixed_alignment(1, 1)], None)).doc;
|
||||
assert!(texts(&d)[0].paras.iter().all(|p| p.left_indent == 0.0 && p.right_indent == 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_text_hangs_from_cap_height_and_shows_lines_whose_baseline_fits() {
|
||||
use vectorcraft_doc::text::FirstBaseline;
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
// Room for both lines: where they go.
|
||||
let loaded = open("tall.af", &canvas_with(vec![two_line_frame(1000.0)], None));
|
||||
let t = texts(&loaded.doc)[0];
|
||||
assert_eq!(t.area.first_baseline, FirstBaseline::CapHeight);
|
||||
let lines = vectorcraft_text::layout(db, t).lines;
|
||||
assert_eq!(lines.len(), 2);
|
||||
// Higher than VectorCraft's default, the font's ascent.
|
||||
let ascent = vectorcraft_text::layout(db, &TextObject { area: Default::default(), ..t.clone() }).lines[0].baseline;
|
||||
assert!(lines[0].baseline < ascent - 1.0, "first baseline {} vs ascent {ascent}", lines[0].baseline);
|
||||
let (baseline, bottom) = (lines[1].baseline, lines[1].baseline + lines[1].descent);
|
||||
// A frame the second line's baseline fits in but not its descenders: Affinity shows the line.
|
||||
let height = (baseline + bottom) / 2.0;
|
||||
let loaded = open("short.af", &canvas_with(vec![two_line_frame(height)], None));
|
||||
assert!(loaded.warnings.iter().any(|w| w.contains("made taller so the lines show")), "{:?}", loaded.warnings);
|
||||
let t = texts(&loaded.doc)[0];
|
||||
let lay = vectorcraft_text::layout(db, t);
|
||||
assert_eq!(lay.lines.len(), 2);
|
||||
assert!(!lay.overflow);
|
||||
assert!(frame(t).height() > height && frame(t).height() < bottom + 1.0, "{:?}", frame(t));
|
||||
// One whose second baseline is outside it too: that line stays out, as in Affinity.
|
||||
let loaded = open("shorter.af", &canvas_with(vec![two_line_frame(baseline - 5.0)], None));
|
||||
let t = texts(&loaded.doc)[0];
|
||||
assert_eq!(vectorcraft_text::layout(db, t).lines.len(), 1);
|
||||
assert!((frame(t).height() - (baseline - 5.0)).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn underlined_and_struck_through_characters_stay_so() {
|
||||
let decorated = |ints: &[i32]| {
|
||||
let mut a = attrs("Inter", "Inter-Regular", 400, false, 40.0);
|
||||
if let F::Obj(_, fields) = &mut a {
|
||||
for (t, v) in fields.iter_mut() {
|
||||
if *t == tag(b"Ints") {
|
||||
*v = array_i32(b"Ints", ints);
|
||||
}
|
||||
}
|
||||
}
|
||||
a
|
||||
};
|
||||
let blk = block(
|
||||
utf8("Plain under struck"),
|
||||
vec![
|
||||
run(6, Some(attrs("Inter", "Inter-Regular", 400, false, 40.0))),
|
||||
run(12, Some(decorated(&[1, 0, 0, 0, 1]))),
|
||||
run(18, Some(decorated(&[0, 1, 0, 0, 1]))),
|
||||
],
|
||||
&[0],
|
||||
);
|
||||
let node = F::Def(
|
||||
70,
|
||||
vec![tag(b"TxtA")],
|
||||
vec![
|
||||
(tag(b"StSt"), F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![blk]))])),
|
||||
(tag(b"TxtH"), F::Obj(tag(b"ArFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 400.0, 100.0])), (tag(b"ArtV"), F::F64(80.0))])),
|
||||
],
|
||||
);
|
||||
let loaded = open("underline.af", &canvas_with(vec![node], None));
|
||||
assert!(!loaded.warnings.iter().any(|w| w.contains("decoration")), "{:?}", loaded.warnings);
|
||||
let t = texts(&loaded.doc)[0];
|
||||
assert_eq!(
|
||||
t.runs.iter().map(|r| (r.text.as_str(), r.style.underline, r.style.strikethrough)).collect::<Vec<_>>(),
|
||||
[("Plain ", false, false), ("under ", true, false), ("struck", false, true)]
|
||||
);
|
||||
}
|
||||
|
||||
/// The image of the node named "Photo", and the blob it shows.
|
||||
fn photo(d: &Document) -> &vectorcraft_doc::ImageBlob {
|
||||
let NodeKind::Image(img) = &named(d, "Photo").kind else { panic!("an image") };
|
||||
&d.images[&img.key]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmyk_pixel_layers_show_through_the_documents_cmyk_profile() {
|
||||
use vectorcraft_color::cms::{self, IccProfile, Intent};
|
||||
// A CMYK profile written from a built-in space, embedded as the document's.
|
||||
let icc = cms::icc_bytes(cms::GENERIC_CMYK).unwrap();
|
||||
let loaded = open("photo.af", &cmyk_layer(false, Some(&icc)));
|
||||
let blob = photo(&loaded.doc);
|
||||
assert!(!blob.is_cmyk());
|
||||
let px = image::load_from_memory(&blob.bytes).unwrap().to_rgba8();
|
||||
let profile = IccProfile::from_bytes(None, &icc).unwrap();
|
||||
let srgb = cms::icc::builtin_rgb(cms::SRGB).unwrap();
|
||||
let want = profile.to_rgb_of(&srgb, &[1.0, 0.0, 0.0, 0.0], Intent::RelativeColorimetric, true).unwrap().map(|v| (v * 255.0).round() as i32);
|
||||
let got = px.get_pixel(8, 8).0;
|
||||
assert!((0..3).all(|i| (i32::from(got[i]) - want[i]).abs() <= 2) && got[3] == 255, "{got:?} vs {want:?}");
|
||||
// Not the profile-free formula's pure cyan.
|
||||
assert_ne!(&got[..3], &[0, 255, 255]);
|
||||
assert!(!loaded.warnings.iter().any(|w| w.contains("CMYK")), "{:?}", loaded.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmyk_pixel_layers_without_a_profile_keep_their_inks_or_use_the_working_profile() {
|
||||
// Opaque: a CMYK image with the layer's inks.
|
||||
let loaded = open("photo.af", &cmyk_layer(false, None));
|
||||
let inks = photo(&loaded.doc).cmyk().expect("a CMYK image");
|
||||
assert_eq!((inks.width, inks.height, &inks.data[..4]), (16, 16, &[255, 0, 0, 0][..]));
|
||||
// With transparency: RGBA through the working CMYK profile, and said.
|
||||
let loaded = open("photo.af", &cmyk_layer(true, None));
|
||||
let blob = photo(&loaded.doc);
|
||||
assert!(!blob.is_cmyk());
|
||||
assert_eq!(image::load_from_memory(&blob.bytes).unwrap().to_rgba8().get_pixel(8, 8).0[3], 0);
|
||||
assert!(loaded.warnings.iter().any(|w| w.contains("CMYK pixel layers with transparency")), "{:?}", loaded.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objects_beyond_the_document_range_are_left_out() {
|
||||
// 5 million points from the origin, past `MAX_COORD`.
|
||||
let far = named_rect(11, "Far box", (5.0e6, 0.0), (10.0, 10.0));
|
||||
let loaded = open("far.af", &canvas_with(vec![rectangle(10, (0.0, 0.0), (50.0, 50.0)), far], None));
|
||||
let names: Vec<_> = all(&loaded.doc).iter().filter_map(|n| n.name.clone()).collect();
|
||||
assert!(names.iter().any(|n| n == "Magenta box") && !names.iter().any(|n| n == "Far box"), "{names:?}");
|
||||
assert!(loaded.warnings.iter().any(|w| w.contains("beyond the document range")), "{:?}", loaded.warnings);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
@@ -268,6 +268,133 @@ proptest! {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Affinity documents with every feature the reader maps ----------
|
||||
|
||||
#[path = "support/affinity_docs.rs"]
|
||||
mod affinity_docs;
|
||||
#[path = "../../affinity/tests/support/text_fixture.rs"]
|
||||
mod text_fixture;
|
||||
|
||||
/// One sample document: its object stream and, for embedded content, the embedded document's.
|
||||
struct AffinitySample {
|
||||
name: &'static str,
|
||||
stream: Vec<u8>,
|
||||
embedded: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Original synthetic documents (not an Affinity writer) reaching what the reader maps beyond
|
||||
/// [`affinity_native_stream`]: layer effects and colour overlays (also over tables), stroke
|
||||
/// styles, dashes and brushes, paragraph alignment, frame text, underline, strikethrough and
|
||||
/// line breaks inside a paragraph, tables with merged and two-line cells, master pages, CMYK
|
||||
/// pixel layers with and without the document's CMYK profile, and embedded documents.
|
||||
fn affinity_feature_samples() -> Vec<AffinitySample> {
|
||||
use affinity_docs::*;
|
||||
let identity = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0];
|
||||
let fx = vec![shadow(), effect(b"OutG", 6.0), effect(b"InnS", 3.0), effect(b"BevE", 4.0), outline(0, 3.0), outline(2, 4.0), colour_overlay(0.5)];
|
||||
let canvas = canvas_stream(vec![
|
||||
with_effects(stroked(10, 1, &[]), fx),
|
||||
stroked(11, 0, &[]),
|
||||
stroked(12, 2, &[4.0, 2.0]),
|
||||
stroked(13, 3, &[]),
|
||||
mixed_alignment(1, 2),
|
||||
two_line_frame(150.0),
|
||||
decorated_text(70),
|
||||
with_effects(table_with(30, identity, ["A", "B\u{2028}two", "C", "D", "E"]), vec![colour_overlay(1.0)]),
|
||||
]);
|
||||
let icc = vectorcraft_color::cms::icc_bytes(vectorcraft_color::cms::GENERIC_CMYK).unwrap();
|
||||
let inner = canvas_stream(vec![with_effects(rectangle(10, (0.0, 0.0), (50.0, 50.0)), vec![colour_overlay(1.0)]), artistic_text(20, "Hi", 40.0)]);
|
||||
vec![
|
||||
AffinitySample { name: "effects, strokes, text and tables", stream: canvas, embedded: None },
|
||||
AffinitySample { name: "master pages", stream: master_stream(3), embedded: None },
|
||||
AffinitySample { name: "CMYK pixels with a profile", stream: cmyk_stream(false, Some(&icc)), embedded: None },
|
||||
AffinitySample { name: "CMYK pixels with transparency", stream: cmyk_stream(true, None), embedded: None },
|
||||
AffinitySample { name: "embedded document", stream: canvas_stream(vec![embedded_node(20)]), embedded: Some(inner) },
|
||||
]
|
||||
}
|
||||
|
||||
/// A sample's file: its streams stored uncompressed, so mutations reach the object graph
|
||||
/// instead of failing a decompressor's checks.
|
||||
fn affinity_sample_file(stream: &[u8], embedded: Option<&[u8]>) -> Vec<u8> {
|
||||
use vectorcraft_affinity::synth::Method;
|
||||
let inner = embedded.map(|e| affinity_docs::packed(e, None, Method::Stored));
|
||||
affinity_docs::packed(stream, inner.as_deref(), Method::Stored)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_affinity_feature_samples_reach_what_they_are_for() {
|
||||
use vectorcraft_doc::NodeKind;
|
||||
let samples = affinity_feature_samples();
|
||||
let open = |i: usize| {
|
||||
let s: &AffinitySample = &samples[i];
|
||||
let l = vectorcraft_engine::cmd::fileio::load("x.af", &affinity_sample_file(&s.stream, s.embedded.as_deref())).unwrap();
|
||||
assert!(!l.preview_only, "{}: {:?}", s.name, l.warnings);
|
||||
l
|
||||
};
|
||||
fn all(d: &Document) -> Vec<&vectorcraft_doc::Node> {
|
||||
fn walk<'a>(nodes: &'a [std::sync::Arc<vectorcraft_doc::Node>], out: &mut Vec<&'a vectorcraft_doc::Node>) {
|
||||
for n in nodes {
|
||||
out.push(n);
|
||||
if let Some(c) = n.children() {
|
||||
walk(c, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
walk(&d.layers, &mut out);
|
||||
out
|
||||
}
|
||||
let l = open(0);
|
||||
let nodes = all(&l.doc);
|
||||
let runs: Vec<&vectorcraft_doc::text::TextRun> =
|
||||
nodes.iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.runs.iter()) } else { None }).flatten().collect();
|
||||
assert!(runs.iter().any(|r| r.style.underline) && runs.iter().any(|r| r.style.strikethrough));
|
||||
assert!(runs.iter().any(|r| r.text.contains("two")), "the table's two-line cell");
|
||||
assert!(nodes.iter().any(|n| n.appearance.effects.iter().any(|e| e.id == "stylize.dropShadow")));
|
||||
assert!(l.warnings.iter().any(|w| w.contains("bevel")), "{:?}", l.warnings);
|
||||
// Line styles: solid, None (no stroke), dashed and brush.
|
||||
use vectorcraft_doc::appearance::{AppearanceItem, StrokeLayer};
|
||||
let strokes = |n: &vectorcraft_doc::Node| -> Vec<StrokeLayer> {
|
||||
n.appearance.items.iter().filter_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s.clone()) } else { None }).collect()
|
||||
};
|
||||
let boxes: Vec<_> = nodes.iter().filter(|n| n.name.as_deref() == Some("Magenta box")).collect();
|
||||
assert_eq!(boxes.len(), 4);
|
||||
assert_eq!(boxes.iter().filter(|b| strokes(b).is_empty()).count(), 1, "line style None");
|
||||
assert!(boxes.iter().any(|b| strokes(b).iter().any(|s| s.dash.is_some())), "dashed");
|
||||
let l = open(1);
|
||||
assert_eq!(l.doc.artboards.len(), 3);
|
||||
assert!(l.doc.layers.iter().any(|n| n.name.as_deref() == Some("Master A")));
|
||||
for i in [2, 3] {
|
||||
assert!(all(&open(i).doc).iter().any(|n| matches!(n.kind, NodeKind::Image(_))), "{}", samples[i].name);
|
||||
}
|
||||
assert!(all(&open(4).doc).iter().any(|n| n.name.as_deref() == Some("Magenta box")));
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(config())]
|
||||
|
||||
#[test]
|
||||
fn affinity_feature_documents_with_mutated_streams_render_and_export(
|
||||
sample in 0usize..5,
|
||||
inner in any::<bool>(),
|
||||
cut in 0usize..8192,
|
||||
edits in prop::collection::vec((0usize..8192, any::<u8>()), 0..24),
|
||||
) {
|
||||
let samples = affinity_feature_samples();
|
||||
let s = &samples[sample % samples.len()];
|
||||
let (mut stream, mut embedded) = (s.stream.clone(), s.embedded.clone());
|
||||
// Mutate the embedded document's stream instead, where there is one.
|
||||
let target = match embedded.as_mut() {
|
||||
Some(e) if inner => e,
|
||||
_ => &mut stream,
|
||||
};
|
||||
let n = target.len();
|
||||
for (at, b) in edits { target[at % n] = b; }
|
||||
target.truncate(cut.max(16));
|
||||
let bytes = affinity_sample_file(&stream, embedded.as_deref());
|
||||
survive(s.name, || vectorcraft_engine::cmd::fileio::load("x.af", &bytes).ok().map(|l| l.doc))?;
|
||||
}
|
||||
}
|
||||
|
||||
/// Import must not panic; whatever comes back must render and export without panicking either.
|
||||
fn survive(what: &str, import: impl FnOnce() -> Option<Document>) -> Result<(), TestCaseError> {
|
||||
let r = catch_quiet(|| {
|
||||
@@ -4046,3 +4173,15 @@ fn ai_cut_inside_an_array_or_dictionary_opens_without_panicking() {
|
||||
survive("ai cut inside an array or dictionary", || vectorcraft_engine::cmd::fileio::load("saved.ai", &b).ok().map(|l| l.doc)).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// Found by the fuzzer above: a shape 3.3 million points long and 50 high, with a drop shadow,
|
||||
/// made the PDF export rasterise its shadow wider than the renderer can address (within the
|
||||
/// effect image's area limit, past any side), which panicked.
|
||||
#[test]
|
||||
fn a_long_thin_shape_with_a_shadow_renders_and_exports() {
|
||||
use affinity_docs::*;
|
||||
let stream = canvas_stream(vec![with_effects(rectangle(10, (0.0, 0.0), (3_276_800.0, 50.0)), vec![shadow()])]);
|
||||
let bytes = affinity_sample_file(&stream, None);
|
||||
let doc = vectorcraft_engine::cmd::fileio::load("long.af", &bytes).unwrap().doc;
|
||||
survive("long shape", || Some(doc)).unwrap();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
//! Original synthetic Affinity documents for the engine's Affinity tests and the import fuzzer,
|
||||
//! built with the reader's `synth` feature in the layout the reader accepts (no Affinity
|
||||
//! software or files were used).
|
||||
#![allow(dead_code)]
|
||||
|
||||
use crate::text_fixture::{array_f64, array_i32, attrs, block, run, shared_objects, utf8};
|
||||
use vectorcraft_affinity::synth::{self, F, Method, tag};
|
||||
|
||||
pub type Field = (vectorcraft_affinity::stream::Tag, F);
|
||||
|
||||
/// Encode a stream whose shared arrays may list inline objects.
|
||||
pub fn stream(mut root: Vec<Field>) -> Vec<u8> {
|
||||
let mut next_id = 1000;
|
||||
for (_, value) in &mut root {
|
||||
shared_objects(value, &mut next_id);
|
||||
}
|
||||
synth::stream(&root)
|
||||
}
|
||||
|
||||
/// 300 dpi: a document pixel is 0.24 pt.
|
||||
pub const DPI: f64 = 300.0;
|
||||
|
||||
pub const PT: f64 = 72.0 / DPI;
|
||||
|
||||
pub fn rgba(r: f32, g: f32, b: f32) -> F {
|
||||
F::Obj(tag(b"RGBA"), vec![(tag(b"_col"), F::Struct([r, g, b, 1.0].iter().flat_map(|v| v.to_le_bytes()).collect()))])
|
||||
}
|
||||
|
||||
pub fn cmyk(c: f32, m: f32, y: f32, k: f32) -> F {
|
||||
F::Obj(
|
||||
tag(b"FilS"),
|
||||
vec![(tag(b"Colr"), F::Obj(tag(b"CMYK"), vec![(tag(b"_col"), F::Struct([c, m, y, k, 1.0].iter().flat_map(|v| v.to_le_bytes()).collect()))]))],
|
||||
)
|
||||
}
|
||||
|
||||
/// A magenta rectangle named `name`.
|
||||
pub fn named_rect(id: u32, name: &str, at: (f64, f64), size: (f64, f64)) -> F {
|
||||
F::Def(
|
||||
id,
|
||||
vec![tag(b"ShpN")],
|
||||
vec![
|
||||
(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, size.0, size.1])),
|
||||
(tag(b"Shpe"), F::Def(id + 10_000, vec![tag(b"ShNR")], vec![])),
|
||||
(tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, at.0, 0.0, 1.0, at.1])),
|
||||
(tag(b"BFil"), cmyk(0.0, 1.0, 0.0, 0.0)),
|
||||
(tag(b"Desc"), F::Str(name.into())),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn rectangle(id: u32, at: (f64, f64), size: (f64, f64)) -> F {
|
||||
named_rect(id, "Magenta box", at, size)
|
||||
}
|
||||
|
||||
/// An artboard (`ABEn`) named `name` over `bounds` at `at`, holding `children`.
|
||||
pub fn board(id: u32, name: &str, at: (f64, f64), size: (f64, f64), children: Vec<F>) -> F {
|
||||
F::Def(
|
||||
id,
|
||||
vec![tag(b"ShpN")],
|
||||
vec![
|
||||
(tag(b"Desc"), F::Str(name.into())),
|
||||
(tag(b"ABEn"), F::Bool(true)),
|
||||
(tag(b"Shpe"), F::Def(id + 10_000, vec![tag(b"ShNR")], vec![])),
|
||||
(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, size.0, size.1])),
|
||||
(tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, at.0, 0.0, 1.0, at.1])),
|
||||
(tag(b"Chld"), F::Shared(children)),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn artistic_text(id: u32, text: &str, size: f64) -> F {
|
||||
F::Def(
|
||||
id,
|
||||
vec![tag(b"TxtA")],
|
||||
vec![
|
||||
(
|
||||
tag(b"StSt"),
|
||||
F::Obj(
|
||||
tag(b"Stry"),
|
||||
vec![(
|
||||
tag(b"Blok"),
|
||||
F::Shared(vec![block(
|
||||
utf8(text),
|
||||
vec![run(text.chars().count() as i32, Some(attrs("Inter", "Inter-Bold", 700, false, size)))],
|
||||
&[1],
|
||||
)]),
|
||||
)],
|
||||
),
|
||||
),
|
||||
(tag(b"TxtH"), F::Obj(tag(b"ArFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 400.0, 100.0])), (tag(b"ArtV"), F::F64(80.0))])),
|
||||
(tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, 1000.0, 0.0, 1.0, 500.0])),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn page(x0: f64, w: f64, h: f64) -> F {
|
||||
F::Obj(tag(b"PagR"), vec![(tag(b"rctp"), F::F64s(vec![x0, 0.0, x0 + w, h]))])
|
||||
}
|
||||
|
||||
/// A Publisher spread of A4 pages at 300 dpi holding `nodes`.
|
||||
pub fn spread(id: u32, pages: usize, nodes: Vec<F>) -> F {
|
||||
let w = 2480.0 * pages as f64;
|
||||
F::Def(
|
||||
id,
|
||||
vec![tag(b"Sprd")],
|
||||
vec![
|
||||
(tag(b"SprB"), F::F64s(vec![0.0, 0.0, w, 3508.0])),
|
||||
(
|
||||
tag(b"SpMd"),
|
||||
F::Obj(tag(b"SpMd"), vec![(tag(b"PagR"), F::Shared((0..pages).map(|i| page(2480.0 * i as f64, 2480.0, 3508.0)).collect()))]),
|
||||
),
|
||||
(tag(b"Chld"), F::Shared(nodes)),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn publisher_with(spreads: Vec<F>) -> Vec<u8> {
|
||||
let data = stream(vec![
|
||||
(tag(b"UVCn"), F::Obj(tag(b"UVCn"), vec![(tag(b"UPPI"), F::F64(DPI))])),
|
||||
(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(spreads))])),
|
||||
]);
|
||||
synth::container(&[("doc.dat", &data, Method::Zstd)], None)
|
||||
}
|
||||
|
||||
/// A two-page spread: a layer with a CMYK rectangle, and centred artistic text.
|
||||
pub fn publisher() -> Vec<u8> {
|
||||
let layer = F::Def(
|
||||
3,
|
||||
vec![tag(b"Scop")],
|
||||
vec![(tag(b"Desc"), F::Str("Background".into())), (tag(b"Chld"), F::Shared(vec![rectangle(10, (100.0, 100.0), (100.0, 200.0))]))],
|
||||
);
|
||||
publisher_with(vec![spread(2, 2, vec![layer, artistic_text(20, "Hello\u{2029}World\u{2029}", 100.0)])])
|
||||
}
|
||||
|
||||
/// One A4 page at 300 dpi holding `node`.
|
||||
pub fn page_with(node: F) -> Vec<u8> {
|
||||
publisher_with(vec![spread(2, 1, vec![node])])
|
||||
}
|
||||
|
||||
/// A document at 72 dpi holding `nodes` on a canvas without pages; `embedded` is `edc/1`.
|
||||
pub fn canvas_with(nodes: Vec<F>, embedded: Option<&[u8]>) -> Vec<u8> {
|
||||
packed(&canvas_stream(nodes), embedded, Method::Zstd)
|
||||
}
|
||||
|
||||
/// The object stream of [`canvas_with`].
|
||||
pub fn canvas_stream(nodes: Vec<F>) -> Vec<u8> {
|
||||
let spread = F::Def(2, vec![tag(b"Sprd")], vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 1000.0, 1000.0])), (tag(b"Chld"), F::Shared(nodes))]);
|
||||
stream(vec![(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))]))])
|
||||
}
|
||||
|
||||
/// A document of object stream `data`, packed with `method`; `embedded` is entry `edc/1`.
|
||||
pub fn packed(data: &[u8], embedded: Option<&[u8]>, method: Method) -> Vec<u8> {
|
||||
match embedded {
|
||||
Some(inner) => {
|
||||
let mut entry = b"EmDc\0\0\0\0".to_vec();
|
||||
entry.extend_from_slice(inner);
|
||||
synth::container(&[("doc.dat", data, method), ("edc/1", &entry, method)], None)
|
||||
}
|
||||
None => synth::container(&[("doc.dat", data, method)], None),
|
||||
}
|
||||
}
|
||||
|
||||
/// An instance of the embedded document `edc/1`, scaled 2× with its content's centre at (500, 300).
|
||||
pub fn embedded_node(id: u32) -> F {
|
||||
let content = F::Obj(tag(b"EmbC"), vec![(tag(b"EmbC"), F::Entry("edc/1".into()))]);
|
||||
F::Def(
|
||||
id,
|
||||
vec![tag(b"EmbN")],
|
||||
vec![
|
||||
(tag(b"Bitm"), F::Obj(tag(b"EmbR"), vec![(tag(b"EmCn"), content), (tag(b"PBBx"), F::Enum(2, 0))])),
|
||||
(tag(b"Xfrm"), F::F64s(vec![2.0, 0.0, 500.0, 0.0, 2.0, 300.0])),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// A blue 2 px line on a table edge.
|
||||
pub fn table_edge() -> F {
|
||||
F::Obj(
|
||||
tag(b"TEdg"),
|
||||
vec![
|
||||
(tag(b"Line"), F::Obj(tag(b"LDsc"), vec![(tag(b"LDeL"), F::Obj(tag(b"LSty"), vec![(tag(b"Wght"), F::F64(2.0))]))])),
|
||||
(tag(b"Fill"), F::Obj(tag(b"FDsc"), vec![(tag(b"FDeF"), cmyk(1.0, 0.5, 0.0, 0.0))])),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// A 3 × 2 table whose cell (1, 1) is merged into (1, 0); cells "A".."E" in reading order, each
|
||||
/// ended by a cell break. Cells are 100 × 50 px from (0, 0).
|
||||
pub fn table_node(id: u32, transform: [f64; 6]) -> F {
|
||||
table_with(id, transform, ["A", "B", "C", "D", "E"])
|
||||
}
|
||||
|
||||
/// [`table_node`] with these cell texts.
|
||||
pub fn table_with(id: u32, transform: [f64; 6], texts: [&str; 5]) -> F {
|
||||
let brk = || F::Obj(tag(b"BrGl"), vec![(tag(b"HdBk"), F::Enum(4, 1))]);
|
||||
let segments: Vec<F> = texts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, t)| {
|
||||
let mut fields = vec![(tag(b"Utf8"), F::Str((*t).into()))];
|
||||
if i > 0 {
|
||||
fields.insert(0, (tag(b"Glys"), F::Shared(vec![brk()])));
|
||||
}
|
||||
F::Obj(tag(b"GSeg"), fields)
|
||||
})
|
||||
.collect();
|
||||
let len = (texts.iter().map(|t| t.chars().count()).sum::<usize>() + texts.len() - 1) as i32;
|
||||
let glyphs = F::Obj(tag(b"GStr"), vec![(tag(b"Mixd"), F::Shared(segments))]);
|
||||
let story = block(glyphs, vec![run(len, Some(attrs("Inter", "Inter-Regular", 400, false, 40.0)))], &[]);
|
||||
let cell = |merged: bool| {
|
||||
let mut f = vec![(tag(b"AliY"), F::Enum(1, 0)), (tag(b"Inse"), F::F64s(vec![5.0, 6.0, 7.0, 8.0]))];
|
||||
if merged {
|
||||
f.push((tag(b"BrLf"), F::Bool(true)));
|
||||
}
|
||||
F::Obj(tag(b"TCel"), f)
|
||||
};
|
||||
let pos = |values: &[f64]| F::Obj(tag(b"TPos"), vec![(tag(b"Posn"), array_f64(b"Posn", values))]);
|
||||
let edges = |n: usize| F::Obj(tag(b"TEds"), vec![(tag(b"Edge"), F::Shared((0..n).map(|_| table_edge()).collect()))]);
|
||||
let table = F::Obj(
|
||||
tag(b"Tabl"),
|
||||
vec![
|
||||
(tag(b"CPos"), pos(&[0.0, 100.0, 200.0, 300.0])),
|
||||
(tag(b"RPos"), pos(&[0.0, 50.0, 100.0])),
|
||||
(tag(b"CEdg"), edges(8)),
|
||||
(tag(b"REdg"), edges(9)),
|
||||
(tag(b"Cell"), F::Obj(tag(b"TCls"), vec![(tag(b"Cell"), F::Shared((0..6).map(|i| cell(i == 4)).collect()))])),
|
||||
],
|
||||
);
|
||||
F::Def(
|
||||
id,
|
||||
vec![tag(b"TxtT")],
|
||||
vec![
|
||||
(tag(b"StSt"), F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![story]))])),
|
||||
(tag(b"TxtH"), F::Obj(tag(b"TbFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 300.0, 100.0])), (tag(b"Tabl"), table)])),
|
||||
(tag(b"Xfrm"), F::F64s(transform.to_vec())),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// Character attributes without their own fixed leading.
|
||||
pub fn auto_leading(mut attrs: F) -> F {
|
||||
if let F::Obj(_, fields) = &mut attrs {
|
||||
for (t, v) in fields.iter_mut() {
|
||||
if *t == tag(b"Ints") {
|
||||
*v = array_i32(b"Ints", &[0, 0, 0, 0, 0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
attrs
|
||||
}
|
||||
|
||||
/// A two-page master spread (pages 0–100 and 100–200 wide) with a box on each page; the
|
||||
/// document's page shows its second page, as Affinity's instance says (MPOf 1).
|
||||
pub fn with_master(pages: usize) -> Vec<u8> {
|
||||
packed(&master_stream(pages), None, Method::Zstd)
|
||||
}
|
||||
|
||||
/// The object stream of [`with_master`].
|
||||
pub fn master_stream(pages: usize) -> Vec<u8> {
|
||||
let master = F::Def(
|
||||
7,
|
||||
vec![tag(b"Sprd")],
|
||||
vec![
|
||||
(tag(b"SpMd"), F::Obj(tag(b"SpMd"), vec![(tag(b"PagR"), F::Shared(vec![page(0.0, 100.0, 150.0), page(100.0, 100.0, 150.0)]))])),
|
||||
(
|
||||
tag(b"Chld"),
|
||||
F::Shared(vec![
|
||||
named_rect(30, "Left master box", (10.0, 10.0), (20.0, 20.0)),
|
||||
named_rect(31, "Right master box", (110.0, 10.0), (30.0, 40.0)),
|
||||
]),
|
||||
),
|
||||
],
|
||||
);
|
||||
let instance = F::Def(
|
||||
5,
|
||||
vec![tag(b"MPIN")],
|
||||
vec![
|
||||
(tag(b"SLnk"), F::Obj(tag(b"ILSN"), vec![(tag(b"ILOb"), F::Shared(vec![master, F::Ref(5)]))])),
|
||||
(tag(b"PgOf"), F::U32(0)),
|
||||
(tag(b"MPOf"), F::U32(1)),
|
||||
(tag(b"PgCt"), F::U32(pages as u32)),
|
||||
],
|
||||
);
|
||||
let spread = F::Def(
|
||||
2,
|
||||
vec![tag(b"Sprd")],
|
||||
vec![
|
||||
(tag(b"SpMd"), F::Obj(tag(b"SpMd"), vec![(tag(b"PagR"), F::Shared((0..pages).map(|i| page(100.0 * i as f64, 100.0, 150.0)).collect()))])),
|
||||
(tag(b"Chld"), F::Shared(vec![instance, named_rect(40, "Page box", (50.0, 100.0), (10.0, 10.0))])),
|
||||
],
|
||||
);
|
||||
stream(vec![(
|
||||
tag(b"DocR"),
|
||||
F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread])), (tag(b"MpCh"), F::Shared(vec![F::Ref(7)]))]),
|
||||
)])
|
||||
}
|
||||
|
||||
pub fn with_effects(mut node: F, effects: Vec<F>) -> F {
|
||||
if let F::Def(_, _, fields) = &mut node {
|
||||
fields.push((tag(b"FiEf"), F::Shared(effects)));
|
||||
}
|
||||
node
|
||||
}
|
||||
|
||||
/// Artistic text "Wide line" over "Mid", the paragraphs aligned `first` and `second`
|
||||
/// (0 left, 1 centre, 2 right).
|
||||
pub fn mixed_alignment(first: i32, second: i32) -> F {
|
||||
let para = |end: i32, align: i32| {
|
||||
vec![(tag(b"Indx"), F::I32(end)), (tag(b"Item"), F::Obj(tag(b"PAtt"), vec![(tag(b"Ints"), array_i32(b"Ints", &[align]))]))]
|
||||
};
|
||||
let blk = F::Obj(
|
||||
tag(b"StBl"),
|
||||
vec![
|
||||
(tag(b"Glyp"), utf8("Wide line\u{2029}Mid\u{2029}")),
|
||||
(
|
||||
tag(b"GAtt"),
|
||||
F::Obj(
|
||||
tag(b"GlAS"),
|
||||
vec![(tag(b"Runs"), F::Objs(tag(b"GlAR"), vec![run(14, Some(attrs("Inter", "Inter-Regular", 400, false, 40.0)))]))],
|
||||
),
|
||||
),
|
||||
(tag(b"PAtt"), F::Obj(tag(b"PaAS"), vec![(tag(b"Runs"), F::Objs(tag(b"PaAR"), vec![para(10, first), para(14, second)]))])),
|
||||
],
|
||||
);
|
||||
F::Def(
|
||||
50,
|
||||
vec![tag(b"TxtA")],
|
||||
vec![
|
||||
(tag(b"StSt"), F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![blk]))])),
|
||||
(tag(b"TxtH"), F::Obj(tag(b"ArFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 400.0, 100.0])), (tag(b"ArtV"), F::F64(80.0))])),
|
||||
(tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, 100.0, 0.0, 1.0, 100.0])),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// Frame text of two 100 px lines in a frame `height` tall.
|
||||
pub fn two_line_frame(height: f64) -> F {
|
||||
let blk = block(utf8("One\u{2029}Two\u{2029}"), vec![run(8, Some(auto_leading(attrs("Inter", "Inter-Regular", 400, false, 100.0))))], &[0]);
|
||||
F::Def(
|
||||
60,
|
||||
vec![tag(b"TxtF")],
|
||||
vec![
|
||||
(tag(b"StSt"), F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![blk]))])),
|
||||
(tag(b"TxtH"), F::Obj(tag(b"FrFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 500.0, height]))])),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// A 16 × 16 CMYK pixel layer of pure cyan, opaque or (`transparent`) with no alpha, in a
|
||||
/// document that embeds `profile` as its CMYK profile.
|
||||
pub fn cmyk_layer(transparent: bool, profile: Option<&[u8]>) -> Vec<u8> {
|
||||
packed(&cmyk_stream(transparent, profile), None, Method::Zstd)
|
||||
}
|
||||
|
||||
/// The object stream of [`cmyk_layer`].
|
||||
pub fn cmyk_stream(transparent: bool, profile: Option<&[u8]>) -> Vec<u8> {
|
||||
let mut bitmap = vec![(tag(b"Frmt"), F::Enum(4, 0)), (tag(b"BmpW"), F::I32(16)), (tag(b"BmpH"), F::I32(16))];
|
||||
for c in 1..=5 {
|
||||
// Tile state 2: every sample 255; 0: every sample 0.
|
||||
let full = c == 1 || (c == 5 && !transparent);
|
||||
bitmap.push((tag(&[b'T', b'W', b'i', b'0' + c]), F::I32(1)));
|
||||
bitmap.push((tag(&[b'T', b'H', b'i', b'0' + c]), F::I32(1)));
|
||||
bitmap.push((tag(&[b'S', b't', b'a', b'0' + c]), F::U8s(vec![if full { 2 } else { 0 }])));
|
||||
}
|
||||
let layer = F::Def(10, vec![tag(b"Rstr")], vec![(tag(b"Bitm"), F::Obj(tag(b"DyBm"), bitmap)), (tag(b"Desc"), F::Str("Photo".into()))]);
|
||||
let spread = F::Def(2, vec![tag(b"Sprd")], vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 16.0, 16.0])), (tag(b"Chld"), F::Shared(vec![layer]))]);
|
||||
let mut root = vec![(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))]))];
|
||||
if let Some(icc) = profile {
|
||||
root.push((tag(b"CMYP"), F::Obj(tag(b"ICCP"), vec![(tag(b"Data"), blob(b"Data", icc))])));
|
||||
}
|
||||
stream(root)
|
||||
}
|
||||
|
||||
/// A byte blob field, as `LSty` `Data` and `ICCP` `Data` are stored.
|
||||
pub fn blob(field: &[u8; 4], bytes: &[u8]) -> F {
|
||||
let mut b = vec![0x2d];
|
||||
b.extend(tag(field).0.to_le_bytes());
|
||||
b.extend((bytes.len() as u32).to_le_bytes());
|
||||
b.extend_from_slice(bytes);
|
||||
F::Raw(b)
|
||||
}
|
||||
|
||||
/// A drop shadow 3 px below, blurred 2 px.
|
||||
pub fn shadow() -> F {
|
||||
F::Obj(
|
||||
tag(b"Shad"),
|
||||
vec![
|
||||
(tag(b"Enab"), F::Bool(true)),
|
||||
(tag(b"Opac"), F::F64(0.5)),
|
||||
(tag(b"Radi"), F::F64(2.0)),
|
||||
(tag(b"Offs"), F::F64(3.0)),
|
||||
(tag(b"Angl"), F::F64(std::f64::consts::FRAC_PI_2)),
|
||||
(tag(b"Colr"), rgba(0.0, 0.0, 1.0)),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// An effect of `class` (`OutG`, `InnS`, `BevE` …) with this radius, yellow at 80 %.
|
||||
pub fn effect(class: &[u8; 4], radius: f64) -> F {
|
||||
F::Obj(
|
||||
tag(class),
|
||||
vec![(tag(b"Enab"), F::Bool(true)), (tag(b"Opac"), F::F64(0.8)), (tag(b"Radi"), F::F64(radius)), (tag(b"Colr"), rgba(1.0, 1.0, 0.0))],
|
||||
)
|
||||
}
|
||||
|
||||
/// A red outline effect `width` wide, aligned `align` (0 outside, 1 inside, 2 centred).
|
||||
pub fn outline(align: u16, width: f64) -> F {
|
||||
F::Obj(
|
||||
tag(b"Strk"),
|
||||
vec![(tag(b"Enab"), F::Bool(true)), (tag(b"Radi"), F::F64(width)), (tag(b"Alig"), F::Enum(align, 0)), (tag(b"Colr"), rgba(1.0, 0.0, 0.0))],
|
||||
)
|
||||
}
|
||||
|
||||
/// A green colour overlay at `opacity`.
|
||||
pub fn colour_overlay(opacity: f64) -> F {
|
||||
F::Obj(tag(b"ColO"), vec![(tag(b"Enab"), F::Bool(true)), (tag(b"Opac"), F::F64(opacity)), (tag(b"Colr"), rgba(0.0, 1.0, 0.0))])
|
||||
}
|
||||
|
||||
/// A rectangle with a black stroke 2 px wide in line style `style` (`LSty` `Data` byte 10:
|
||||
/// 0 none, 1 solid, 2 dashed, 3 brush), dashed when `dash` has lengths.
|
||||
pub fn stroked(id: u32, style: u8, dash: &[f64]) -> F {
|
||||
let mut node = rectangle(id, (0.0, 0.0), (50.0, 50.0));
|
||||
let mut data = 10.0f64.to_le_bytes().to_vec();
|
||||
data.extend([0, 0, style]);
|
||||
let mut lsty = vec![(tag(b"Wght"), F::F64(2.0)), (tag(b"Data"), blob(b"Data", &data))];
|
||||
if !dash.is_empty() {
|
||||
lsty.push((tag(b"Patn"), array_f64(b"Patn", dash)));
|
||||
}
|
||||
if let F::Def(_, _, fields) = &mut node {
|
||||
fields.push((tag(b"PFil"), cmyk(0.0, 0.0, 0.0, 1.0)));
|
||||
fields.push((tag(b"LSty"), F::Obj(tag(b"LSty"), lsty)));
|
||||
}
|
||||
node
|
||||
}
|
||||
|
||||
/// Character attributes of 40 px Inter with these `Ints` (slot 0 underline, 1 strikethrough).
|
||||
pub fn decorated(ints: &[i32]) -> F {
|
||||
let mut a = attrs("Inter", "Inter-Regular", 400, false, 40.0);
|
||||
if let F::Obj(_, fields) = &mut a {
|
||||
for (t, v) in fields.iter_mut() {
|
||||
if *t == tag(b"Ints") {
|
||||
*v = array_i32(b"Ints", ints);
|
||||
}
|
||||
}
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
/// Artistic text with an underlined run, a struck-through one and a line break inside a
|
||||
/// paragraph.
|
||||
pub fn decorated_text(id: u32) -> F {
|
||||
let blk = block(
|
||||
utf8("Plain under\u{2028}struck\u{2029}"),
|
||||
vec![
|
||||
run(6, Some(attrs("Inter", "Inter-Regular", 400, false, 40.0))),
|
||||
run(12, Some(decorated(&[1, 0, 0, 0, 1]))),
|
||||
run(19, Some(decorated(&[0, 1, 0, 0, 1]))),
|
||||
],
|
||||
&[0],
|
||||
);
|
||||
F::Def(
|
||||
id,
|
||||
vec![tag(b"TxtA")],
|
||||
vec![
|
||||
(tag(b"StSt"), F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![blk]))])),
|
||||
(tag(b"TxtH"), F::Obj(tag(b"ArFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 400.0, 100.0])), (tag(b"ArtV"), F::F64(80.0))])),
|
||||
],
|
||||
)
|
||||
}
|
||||
@@ -82,6 +82,11 @@ pub fn drawn_outline(doc: &Document, n: &Node) -> Option<(BezPath, FillRule)> {
|
||||
/// Largest raster an export may ask [`Renderer::render_region`] for, per side. The CPU rasteriser
|
||||
/// addresses at most `u16::MAX` pixels per side (and panics at that edge).
|
||||
pub const MAX_RASTER_SIDE: u32 = 32_768;
|
||||
/// Largest side of a render context the renderer makes. The CPU rasteriser keeps pixel positions
|
||||
/// in `u16` and overflows near `u16::MAX` (its tile and bucket arithmetic rounds past it), so
|
||||
/// contexts stay within the side raster exports are limited to.
|
||||
const MAX_CONTEXT_SIDE: u32 = MAX_RASTER_SIDE;
|
||||
|
||||
/// Largest raster an export may ask for in total (16384², 1 GiB of RGBA pixels).
|
||||
pub const MAX_RASTER_PIXELS: u64 = 1 << 28;
|
||||
|
||||
@@ -282,7 +287,7 @@ const MAX_SUPERSAMPLED_PIXELS: u64 = 40_000_000;
|
||||
pub fn supersample_factor(w: u32, h: u32) -> Option<u32> {
|
||||
(2..=4u32).rev().find(|k| {
|
||||
let (sw, sh) = (u64::from(w) * u64::from(*k), u64::from(h) * u64::from(*k));
|
||||
sw <= u64::from(u16::MAX) && sh <= u64::from(u16::MAX) && sw * sh <= MAX_SUPERSAMPLED_PIXELS
|
||||
sw <= u64::from(MAX_CONTEXT_SIDE) && sh <= u64::from(MAX_CONTEXT_SIDE) && sw * sh <= MAX_SUPERSAMPLED_PIXELS
|
||||
})
|
||||
}
|
||||
|
||||
@@ -667,7 +672,7 @@ impl Renderer {
|
||||
fn render_as(&mut self, doc: &Document, width: u32, height: u32, view: Affine, opts: &RenderOptions, inks: bool) -> Rendered {
|
||||
let start = now();
|
||||
if opts.supersample && opts.anti_alias == AntiAlias::Art && opts.mask_view.is_none() {
|
||||
let (w, h) = (width.clamp(1, u16::MAX as u32), height.clamp(1, u16::MAX as u32));
|
||||
let (w, h) = (width.clamp(1, MAX_CONTEXT_SIDE), height.clamp(1, MAX_CONTEXT_SIDE));
|
||||
if let Some(k) = supersample_factor(w, h) {
|
||||
let hard = RenderOptions { anti_alias: AntiAlias::None, supersample: false, ..opts.clone() };
|
||||
let big = self.render_as(doc, w * k, h * k, Affine::scale(f64::from(k)) * view, &hard, inks);
|
||||
@@ -681,8 +686,8 @@ impl Renderer {
|
||||
self.check_symbols(doc);
|
||||
let prepared = proof::prepare(doc, opts, &mut self.proof_art);
|
||||
let doc: &Document = &prepared;
|
||||
let w = width.clamp(1, u16::MAX as u32) as u16;
|
||||
let h = height.clamp(1, u16::MAX as u32) as u16;
|
||||
let w = width.clamp(1, MAX_CONTEXT_SIDE) as u16;
|
||||
let h = height.clamp(1, MAX_CONTEXT_SIDE) as u16;
|
||||
// Raster filters (drop shadow, glows, blur) are rendered offscreen on this thread when the
|
||||
// main context is multithreaded (see `fx::with_filters`).
|
||||
let threads = self.threads;
|
||||
@@ -910,7 +915,7 @@ impl Renderer {
|
||||
let b = brush_fx::cull_bounds(&doc.symbol_boxes(), n)?;
|
||||
let s = (size as f64 - 2.0) / b.width().max(b.height()).max(1e-6);
|
||||
let view = Affine::translate((size as f64 / 2.0, size as f64 / 2.0)) * Affine::scale(s) * Affine::translate(-b.center().to_vec2());
|
||||
let w = size.clamp(1, u16::MAX as u32) as u16;
|
||||
let w = size.clamp(1, MAX_CONTEXT_SIDE) as u16;
|
||||
let mut ctx = single_threaded_context(w, w);
|
||||
if let Some(bg) = background {
|
||||
ctx.set_paint(peniko::Color::from_rgba8(bg[0], bg[1], bg[2], bg[3]));
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user