mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-28 19:57:05 -06:00
AF.5: Affinity import — strokes, text decoration, text placement, outline effects, CMYK photos
- Strokes set to None no longer import as black 1px strokes. - Underline and strikethrough are kept. - Frame text starts at cap height; lines hanging out of a frame make it taller; mixed-alignment artistic text lines up within its longest line; line breaks inside a paragraph get no paragraph spacing. - Table cell text taller than its cell is centred and stays visible. - Outline effects on stroked shapes are drawn around the stroke. - CMYK pixel layers go through the document's embedded CMYK profile; without one, opaque layers stay CMYK. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
bflatastic
co-authored by
Claude Opus 5.5
parent
76399175eb
commit
fd50bd6f84
@@ -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
+31
-12
@@ -11,6 +11,7 @@ Affinity files.
|
||||
| Affinity | VectorCraft |
|
||||
|---|---|
|
||||
| Pages, Publisher spreads (one or two pages) | artboards, spreads side by side |
|
||||
| Master pages (`MpCh`) shown on pages through master page instances (`MPIN`: master, page offset, master page, count) | a locked "Master A, B…" layer at the bottom holding each master page's objects on the pages that show it |
|
||||
| Artboards (legacy `ABEn` or Affinity 3 `phrp`/`aprp`), with name, paint and any transform | artboards plus a clip group with the actual vector outline, background and stroke |
|
||||
| Layers (`Scop`), groups (`Grup`), pass-through or isolated | layers and sublayers, groups |
|
||||
| Curves (`PCrv`): cubic subpaths, closed flags, live corners (`CnrD`) | paths (multi-subpath curves fill even-odd) |
|
||||
@@ -21,22 +22,24 @@ Affinity files.
|
||||
| Pixel masks (`MRst`, attached or as a layer in a group) | opacity mask |
|
||||
| Solid fills: RGBA, HSLA, CMYK, grey, Lab (D50) | fill appearance (alpha as fill opacity) |
|
||||
| Linear, elliptical and radial gradients with Affinity's midpoint bias | gradients (the bias as the midpoint where the blend is half way) |
|
||||
| Strokes: weight, scale with object, dash pattern and phase, alignment, miter limit, behind the fill | stroke appearance |
|
||||
| Strokes: weight, scale with object, dash pattern and phase, alignment, miter limit, behind the fill; a stroke whose style is None (every shape stores one, black and 1 px) is left out | stroke appearance |
|
||||
| Fill layers (`FRst`) | filled rectangles |
|
||||
| Artistic and frame text: Unicode scalar-indexed runs, stored fallback font, family/weight/style, boolean OpenType features, all caps, size, tracking, fixed leading, colour, paragraph alignment, first baseline | point type and area type |
|
||||
| Artistic and frame text: Unicode scalar-indexed runs, stored fallback font, family/weight/style, boolean OpenType features, all caps, underline, strikethrough, size, tracking, horizontal scale, fixed leading, colour, outlined characters, first baseline; each paragraph's alignment, indents, space before and after, fixed and automatic leading; frame text sized by its text scale (`FTxS`), not its frame's transform | point type and area type, one paragraph style per paragraph; artistic text whose paragraphs align differently keeps them aligned within its longest line (by indents), as Affinity draws it; a frame's first line hangs from its capitals' tops, and a frame whose last lines' descenders hang out of it is made just tall enough to show them; line breaks inside a paragraph become paragraphs without spacing between them; table cells place their text as Affinity does (centred from the capitals' tops to the last baseline, reaching out of a cell too short for it) |
|
||||
| Tables (`TxtT`): grid, cell text, merged cells, cell insets and vertical alignment, the line on every cell edge | a group of area type per cell and a stroked line per edge (VectorCraft has no tables) |
|
||||
| Placed images (`ImgN`): the original JPEG/PNG embedded in the file | embedded images |
|
||||
| Pixel layers (`Rstr`): RGBA 8/16-bit and CMYK 8-bit tiles, cropped to their content; source-backed RGBA8 JPEG/PNG tiles with matching dimensions and zero origin | embedded PNG images |
|
||||
| Embedded documents and symbols (`EmbN`) | the picture Affinity cached of them |
|
||||
| Pixel layers (`Rstr`): RGBA 8/16-bit and CMYK 8-bit tiles, cropped to their content; source-backed RGBA8 JPEG/PNG tiles with matching dimensions and zero origin; the document's CMYK colour profile (`CMYP`) | embedded PNG images; CMYK layers through the document's profile (relative colorimetric, black-point compensation, as Affinity converts them), or, without one, CMYK images that keep their inks |
|
||||
| Embedded Affinity documents (`EmbN` with an `EmDc` entry), four deep at most | their first page's content, editable (their artboards only clip, they don't become artboards); other embedded documents and symbols as the picture Affinity cached of them |
|
||||
| Layer effects: drop shadows, outer glows, inner shadows; outlines; colour and gradient overlays in any blend mode | Stylize › Drop Shadow and Outer Glow, Inner Glow at the edge (approximated); one more stroke on shapes (around their own stroke), the characters' stroke on text; overlays baked into fill, stroke, text colours and pixels |
|
||||
| Opacity, visibility, lock, names, the blend modes VectorCraft has | the same |
|
||||
|
||||
Everything else is reported in the import warnings, one line per kind with a count, never
|
||||
dropped silently: layer effects, adjustment layers and live filters, brush and pressure strokes,
|
||||
transparency gradients, fill opacity, bitmap fills, master pages, conical gradients (as radial),
|
||||
dropped silently: bevel, blur and other layer effects, adjustment layers and live filters, brush and pressure strokes,
|
||||
transparency gradients, fill opacity, bitmap fills, master pages based on other master pages, conical gradients (as radial),
|
||||
special shapes (cloud, heart, cog, callouts, arrows…, imported as their bounding ellipse), corner
|
||||
types other than round or straight, stars with rounded points, several fills or strokes on one object (the
|
||||
active one is used), outlined or scaled text, text fields such as page numbers, frames with
|
||||
columns or a curved outline, paragraph indents/spacing, mixed paragraph alignments, unsupported font selectors, grey/Lab/32-bit pixels, CMYK pixels (converted to RGB without the
|
||||
document's profile) and Affinity-only blend modes (Add, Linear Light… as Normal).
|
||||
active one is used), skewed or flipped text, characters of different transparency in one text, text fields such as page numbers, text threading, frames with
|
||||
columns or a curved outline, table cell fills, unsupported font selectors, grey/Lab/32-bit pixels, CMYK pixels with transparency in a document
|
||||
without a CMYK profile (converted through VectorCraft's working CMYK profile) and Affinity-only blend modes (Add, Linear Light… as Normal).
|
||||
|
||||
When the native document can't be read at all, the engine opens the embedded PNG preview instead,
|
||||
with a warning that names the reason; that preview can't be placed, used as a template or mined
|
||||
@@ -78,10 +81,22 @@ of CC0 sample files.
|
||||
* **Read-only.** Nothing is written in Affinity's format; export waits until someone can check
|
||||
written files in Affinity itself.
|
||||
|
||||
The reader also has an independent copy in PhotoCraft's `photocraft-affinity`.
|
||||
The reader also has independent copies in PhotoCraft's `photocraft-affinity` and DesignCraft's
|
||||
`designcraft-affinity`. Tables, paragraph formats, text scale, master pages, layer effects,
|
||||
overlays, embedded documents, large pixel layers and HEIC photos were added in DesignCraft
|
||||
([storytold/designcraft#291](https://github.com/storytold/designcraft/pull/291)) and brought back
|
||||
here, from the same public fixtures and the thumbnails of documents their owners made, used only
|
||||
locally. Automatic leading (the font size plus the font's line gap, for artistic and frame text)
|
||||
was fitted on the thumbnails of one such local document with 56 artboards and on the public Patchy
|
||||
text-frame fixture.
|
||||
|
||||
## Validation
|
||||
|
||||
* `engine/tests/affinity_features.rs` covers pages, several artboards per canvas and per spread,
|
||||
master pages, tables, paragraph formats, text scale, layer effects, overlays and embedded
|
||||
documents (whose artboards don't become artboards) on synthetic documents, and mutates them.
|
||||
`engine/tests/affinity_local.rs` compares every artboard of local documents with their thumbnail
|
||||
when `AFFINITY_SAMPLES` points at a folder (never in CI; nothing is committed).
|
||||
* Unit tests on synthetic containers built by the `synth` feature (stored, zlib and zstd entries,
|
||||
checksums, budgets, cycles, hostile streams, every truncation) and property tests of random
|
||||
mutations.
|
||||
@@ -132,8 +147,12 @@ anything is allocated: a value takes about 40 bytes in memory however few it too
|
||||
those 189 public documents uses 3.3 million), 384 levels of object nesting, 128 levels of layers, 500 000 layers, four million curve
|
||||
nodes per curve; compound outlines also cap recursion at 128 levels, operand visits at 100 000
|
||||
and cumulative geometry work at four million, rejecting cycles and partial results on limits.
|
||||
Pie sweeps normalize finite angles in constant time. Pixel layers are limited to 64 megapixels
|
||||
(cropped to their content first); gradients to 1024 stops. Text collection is bounded to a
|
||||
Pie sweeps normalize finite angles in constant time. Pixel layers are decoded at no more than 64
|
||||
megapixels each (cropped to their content first; larger ones are read at a half, a quarter… of
|
||||
their resolution) and 512 megapixels for the whole document, embedded documents included; pixel
|
||||
masks are read only over what they mask. Embedded documents nest four deep at most and count
|
||||
against the importing document's limits. Tables have at most 4096 rows and columns, and 64 layer
|
||||
effects per node are read. Gradients are limited to 1024 stops. Text collection is bounded to a
|
||||
million characters per node before allocation, with a cumulative 16 MiB budget for owned font
|
||||
names, features and gradient stops. Exhaustion warns and uses default attributes for remaining
|
||||
text, preserving its characters.
|
||||
|
||||
@@ -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;
|
||||
|
||||
+530
-50
@@ -1,4 +1,4 @@
|
||||
//! The document model VectorCraft imports: spreads, artboards, layers, groups, curves, shapes,
|
||||
//! The document model the Craft apps import: spreads, artboards, layers, groups, curves, shapes,
|
||||
//! text and images, with transforms composed into document space. Anything else is reported
|
||||
//! by [`Document::warnings`] instead of being dropped silently.
|
||||
|
||||
@@ -175,17 +175,54 @@ pub struct TextRun {
|
||||
/// Boolean OpenType feature overrides, as tags or `-tag` for disabled features.
|
||||
pub features: Vec<String>,
|
||||
pub all_caps: bool,
|
||||
/// Underlined characters (`Ints` slot 0 is 1; identified on underlined words in a document its
|
||||
/// owner made, all other characters there and in the public corpus have 0).
|
||||
pub underline: bool,
|
||||
/// Struck-through characters (`Ints` slot 1 is 1; identified the same way, on prices struck
|
||||
/// through in another document its owner made).
|
||||
pub strikethrough: bool,
|
||||
/// Font size in document pixels, before the node's transform.
|
||||
pub size: f64,
|
||||
/// Tracking in em.
|
||||
pub tracking: f64,
|
||||
/// Horizontal scale of the glyphs (1 = as designed).
|
||||
pub h_scale: f64,
|
||||
/// Fixed line pitch in document pixels, when the run overrides automatic leading.
|
||||
pub leading: Option<f64>,
|
||||
pub fill: Paint,
|
||||
/// Outlined characters: the outline's paint and width (node space).
|
||||
pub stroke: Option<crate::paint::Stroke>,
|
||||
/// The run's paragraph: alignment, indents and spacing.
|
||||
pub paragraph: Paragraph,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
/// Paragraph alignment, indents, spacing and leading, in node space (document pixels before the
|
||||
/// node's text scale).
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Paragraph {
|
||||
pub align: Align,
|
||||
/// Indent of every line but the first, from the left.
|
||||
pub left: f64,
|
||||
pub right: f64,
|
||||
/// Indent of the first line, from the left.
|
||||
pub first: f64,
|
||||
pub space_before: f64,
|
||||
pub space_after: f64,
|
||||
/// Fixed distance between baselines, when the paragraph sets one.
|
||||
pub leading: Option<f64>,
|
||||
/// Automatic leading as a multiple of the font's own line height (ascent plus descent).
|
||||
pub auto_leading: f64,
|
||||
}
|
||||
|
||||
impl Default for Paragraph {
|
||||
fn default() -> Self {
|
||||
Self { align: Align::Left, left: 0.0, right: 0.0, first: 0.0, space_before: 0.0, space_after: 0.0, leading: None, auto_leading: 1.0 }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum Align {
|
||||
#[default]
|
||||
Left,
|
||||
Center,
|
||||
Right,
|
||||
@@ -202,12 +239,67 @@ pub struct Text {
|
||||
pub frame: Option<Rect>,
|
||||
/// Node space to document pixels.
|
||||
pub transform: Affine,
|
||||
/// Node space to document pixels for the type (its linear part matters): artistic text scales
|
||||
/// with its node, frame text only with its frame's text scale.
|
||||
pub text_transform: Affine,
|
||||
}
|
||||
|
||||
/// Vertical alignment of a table cell's text.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum VAlign {
|
||||
Top,
|
||||
Center,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct TableCell {
|
||||
pub runs: Vec<TextRun>,
|
||||
/// Space between the cell edges and its text, in node space: left, top, right, bottom.
|
||||
pub inset: [f64; 4],
|
||||
pub valign: VAlign,
|
||||
/// Part of the cell to its left (merged): it has no text of its own.
|
||||
pub merged_left: bool,
|
||||
}
|
||||
|
||||
/// A table: a grid of cells with text, and a line on every cell edge.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Table {
|
||||
/// Column boundaries (one more than the columns), increasing, in node space.
|
||||
pub columns: Vec<f64>,
|
||||
/// Row boundaries (one more than the rows), increasing, in node space.
|
||||
pub rows: Vec<f64>,
|
||||
/// Row by row.
|
||||
pub cells: Vec<TableCell>,
|
||||
/// Vertical edges row by row, one more per row than the columns; `None` draws no line.
|
||||
pub vertical: Vec<Option<Stroke>>,
|
||||
/// Horizontal edges boundary by boundary (one more than the rows), one per column.
|
||||
pub horizontal: Vec<Option<Stroke>>,
|
||||
/// Node space to document pixels.
|
||||
pub transform: Affine,
|
||||
/// Node space to document pixels for the cells' type: resizing a table moves its grid, not
|
||||
/// its type.
|
||||
pub text_transform: Affine,
|
||||
}
|
||||
|
||||
impl Table {
|
||||
pub fn ncols(&self) -> usize {
|
||||
self.columns.len().saturating_sub(1)
|
||||
}
|
||||
pub fn nrows(&self) -> usize {
|
||||
self.rows.len().saturating_sub(1)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Pixels {
|
||||
/// Straight-alpha RGBA, 8 bits per channel, row by row.
|
||||
Rgba8(Vec<u8>),
|
||||
/// A CMYK pixel layer: cyan, magenta, yellow and black ink amounts (0 none, 255 full) and
|
||||
/// straight alpha, 8 bits each, row by row. Left in CMYK for the importer to show through a
|
||||
/// colour profile: the profile-free formula turns the extra cyan of a separation's neutrals
|
||||
/// into a blue cast.
|
||||
Cmyka8(Vec<u8>),
|
||||
/// The original file a placed image was made from (PNG, JPEG…), as embedded.
|
||||
Encoded(Vec<u8>),
|
||||
}
|
||||
@@ -246,11 +338,78 @@ pub enum Kind {
|
||||
even_odd: bool,
|
||||
},
|
||||
Text(Text),
|
||||
Table(Table),
|
||||
Image(Image),
|
||||
/// A master page shown on this spread's pages: pages `page..page + count` show the master
|
||||
/// spread [`Document::masters`]`[master]`'s pages from `master_page` on.
|
||||
MasterInstance {
|
||||
master: usize,
|
||||
page: usize,
|
||||
master_page: usize,
|
||||
count: usize,
|
||||
},
|
||||
/// Something this reader does not import; its children are still imported.
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
/// A layer effect. Lengths are document pixels; `angle` is the direction the shadow is offset in
|
||||
/// (radians, clockwise from +x as y points down: π/2 puts it below).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Effect {
|
||||
DropShadow {
|
||||
color: crate::paint::Color,
|
||||
opacity: f64,
|
||||
radius: f64,
|
||||
distance: f64,
|
||||
angle: f64,
|
||||
},
|
||||
InnerShadow {
|
||||
color: crate::paint::Color,
|
||||
opacity: f64,
|
||||
radius: f64,
|
||||
distance: f64,
|
||||
angle: f64,
|
||||
},
|
||||
OuterGlow {
|
||||
color: crate::paint::Color,
|
||||
opacity: f64,
|
||||
radius: f64,
|
||||
},
|
||||
/// An outline of `width` around the node's shape: `align` 0 outside, 1 inside, 2 centred.
|
||||
Outline {
|
||||
color: crate::paint::Color,
|
||||
opacity: f64,
|
||||
width: f64,
|
||||
align: u16,
|
||||
},
|
||||
/// Bevel and emboss: lit and shaded edges `size` wide. `angle` is the light's azimuth
|
||||
/// (radians, counter-clockwise from +x with y up), `depth` Affinity's depth (5 by default);
|
||||
/// `inner` is false for the outer, emboss and pillow kinds.
|
||||
Bevel {
|
||||
size: f64,
|
||||
depth: f64,
|
||||
angle: f64,
|
||||
highlight: crate::paint::Color,
|
||||
highlight_opacity: f64,
|
||||
shadow: crate::paint::Color,
|
||||
shadow_opacity: f64,
|
||||
inner: bool,
|
||||
},
|
||||
/// Colour overlay: everything the node paints takes `color`, laid over it in `blend` mode at
|
||||
/// `opacity` (the node's own alpha is kept).
|
||||
ColorOverlay {
|
||||
color: crate::paint::Color,
|
||||
opacity: f64,
|
||||
blend: Blend,
|
||||
},
|
||||
/// Gradient overlay: a colour overlay whose colour comes from `gradient` (document pixels).
|
||||
GradientOverlay {
|
||||
gradient: crate::paint::Gradient,
|
||||
opacity: f64,
|
||||
blend: Blend,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Node {
|
||||
pub class: Tag,
|
||||
@@ -264,6 +423,8 @@ pub struct Node {
|
||||
pub mask: Option<Path>,
|
||||
/// Pixel mask: grey levels (white shows the node, black hides it); outside it the node is hidden.
|
||||
pub pixel_mask: Option<Image>,
|
||||
/// Layer effects the importer can show or bake in (others are reported).
|
||||
pub effects: Vec<Effect>,
|
||||
/// Bottom to top.
|
||||
pub children: Vec<Node>,
|
||||
}
|
||||
@@ -285,12 +446,21 @@ pub struct Document {
|
||||
/// Pixels per inch: all geometry is in document pixels at this resolution.
|
||||
pub dpi: f64,
|
||||
pub spreads: Vec<Spread>,
|
||||
/// Master spreads, which [`Kind::MasterInstance`] nodes show on pages.
|
||||
pub masters: Vec<Spread>,
|
||||
/// Application that last saved the file, as stored (`Affinity 3.0.2`), when present.
|
||||
pub saved_by: Option<String>,
|
||||
/// Content that was not imported or was approximated, one line per kind, deduplicated.
|
||||
pub warnings: Vec<String>,
|
||||
/// The document's CMYK colour profile (an ICC file), when it embeds one: the root's `CMYP`,
|
||||
/// which each CMYK pixel layer's colour settings (`Prof`) also name, with relative
|
||||
/// colorimetric intent and black-point compensation in every document checked.
|
||||
pub cmyk_profile: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Largest embedded colour profile kept (real CMYK profiles are a few megabytes).
|
||||
const MAX_PROFILE_BYTES: usize = 32 << 20;
|
||||
|
||||
/// Deepest layer nesting followed; deeper content is reported, not imported.
|
||||
const MAX_TREE_DEPTH: usize = 128;
|
||||
/// Total layer nodes imported.
|
||||
@@ -300,6 +470,9 @@ const MAX_NODES: usize = 500_000;
|
||||
/// count the bytes extracted from it, and a layer of one flat colour needs none (#1704).
|
||||
const MAX_LAYER_PIXELS: u64 = 512 << 20;
|
||||
|
||||
/// Embedded documents inside embedded documents are followed this deep.
|
||||
const MAX_EMBED_DEPTH: usize = 4;
|
||||
|
||||
/// Read a document: archive, `doc.dat` and the model.
|
||||
pub fn read(bytes: &[u8], limits: Limits) -> Result<Document, Error> {
|
||||
read_within(bytes, limits, MAX_LAYER_PIXELS)
|
||||
@@ -307,35 +480,76 @@ pub fn read(bytes: &[u8], limits: Limits) -> Result<Document, Error> {
|
||||
|
||||
/// [`read`], its pixel layers holding at most `pixels` pixels together.
|
||||
pub(crate) fn read_within(bytes: &[u8], limits: Limits, pixels: u64) -> Result<Document, Error> {
|
||||
read_at(bytes, limits, pixels, Affine::IDENTITY, 0).map(|r| r.document)
|
||||
}
|
||||
|
||||
/// A document read by [`read_at`], with what it used of its budgets.
|
||||
struct ReadAt {
|
||||
document: Document,
|
||||
/// Bytes its archive extracted.
|
||||
extracted: usize,
|
||||
/// Pixels its pixel layers left of the allowance.
|
||||
pixels_left: u64,
|
||||
}
|
||||
|
||||
/// Read a document whose spreads are placed by `base` (for embedded documents, nested `depth`
|
||||
/// deep), its pixel layers holding at most `pixels` pixels together.
|
||||
fn read_at(bytes: &[u8], limits: Limits, pixels: u64, base: Affine, depth: usize) -> Result<ReadAt, Error> {
|
||||
let mut archive = Archive::open(bytes, limits)?;
|
||||
let doc = archive.read("doc.dat")?;
|
||||
let s = stream::parse(&doc)?;
|
||||
let mut reader = Reader::new(&s, &mut archive);
|
||||
let mut reader = Reader::new(&s, &mut archive, base, depth);
|
||||
reader.pixels_left = pixels;
|
||||
reader.document()
|
||||
let (document, pixels_left) = reader.document()?;
|
||||
Ok(ReadAt { document, extracted: archive.extracted(), pixels_left })
|
||||
}
|
||||
|
||||
pub(crate) struct Reader<'s, 'a, 'b> {
|
||||
pub(crate) s: &'s Stream,
|
||||
pub(crate) archive: &'b mut Archive<'a>,
|
||||
pub(crate) dpi: f64,
|
||||
warnings: BTreeMap<String, usize>,
|
||||
pub(crate) warnings: BTreeMap<String, usize>,
|
||||
nodes: usize,
|
||||
active: HashSet<ObjId>,
|
||||
/// Where the spreads go: the identity, or an embedding document's placement.
|
||||
base: Affine,
|
||||
/// The linear part of `base`, which also scales frame text.
|
||||
pub(crate) type_base: Affine,
|
||||
/// How deep inside embedded documents this one is.
|
||||
depth: usize,
|
||||
/// An embedded document's content, waiting to become its node's children.
|
||||
pending: Option<Vec<Node>>,
|
||||
/// The master spreads, in the order of [`Document::masters`].
|
||||
master_ids: Vec<ObjId>,
|
||||
/// Pixels the pixel layers still to come may decode ([`MAX_LAYER_PIXELS`] in all).
|
||||
pub(crate) pixels_left: u64,
|
||||
}
|
||||
|
||||
impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
fn new(s: &'s Stream, archive: &'b mut Archive<'a>) -> Self {
|
||||
Self { s, archive, dpi: 72.0, warnings: BTreeMap::new(), nodes: 0, active: HashSet::new(), pixels_left: MAX_LAYER_PIXELS }
|
||||
pub(crate) fn new(s: &'s Stream, archive: &'b mut Archive<'a>, base: Affine, depth: usize) -> Self {
|
||||
let [a, b, c, d, ..] = base.0;
|
||||
Self {
|
||||
s,
|
||||
archive,
|
||||
dpi: 72.0,
|
||||
warnings: BTreeMap::new(),
|
||||
nodes: 0,
|
||||
active: HashSet::new(),
|
||||
base,
|
||||
type_base: Affine([a, b, c, d, 0.0, 0.0]),
|
||||
depth,
|
||||
pending: None,
|
||||
master_ids: Vec::new(),
|
||||
pixels_left: MAX_LAYER_PIXELS,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn warn(&mut self, what: impl Into<String>) {
|
||||
*self.warnings.entry(what.into()).or_default() += 1;
|
||||
}
|
||||
|
||||
fn document(mut self) -> Result<Document, Error> {
|
||||
/// The document, and the pixels its pixel layers left of the allowance.
|
||||
fn document(mut self) -> Result<(Document, u64), Error> {
|
||||
let s = self.s;
|
||||
let root = s.root;
|
||||
self.dpi = s.obj(root, b"UVCn").and_then(|u| s.f64(u, b"UPPI")).filter(|d| *d > 0.0 && *d < 100_000.0).unwrap_or(72.0);
|
||||
@@ -347,40 +561,65 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
}
|
||||
});
|
||||
let doc = s.obj(root, b"DocR").ok_or(Error::Malformed("document has no root node"))?;
|
||||
self.master_ids = s.objs(doc, b"MpCh").into_iter().filter(|m| s.is(*m, b"Sprd")).collect();
|
||||
let mut spreads = Vec::new();
|
||||
for spread in s.objs(doc, b"Chld") {
|
||||
if !s.is(spread, b"Sprd") {
|
||||
self.warn("an unknown kind of page");
|
||||
continue;
|
||||
if let Some(sp) = self.spread(doc, spread)? {
|
||||
spreads.push(sp);
|
||||
}
|
||||
let pages = pages(s, spread);
|
||||
let default_size =
|
||||
s.floats::<2>(doc, b"DfSz").filter(|[w, h]| *w > 0.0 && *h > 0.0).map(|[w, h]| Rect { x0: 0.0, y0: 0.0, x1: w, y1: h });
|
||||
let bounds = s.floats::<4>(spread, b"SprB").map(rect).or_else(|| pages.iter().copied().reduce(union)).or(default_size).unwrap_or(Rect {
|
||||
x0: 0.0,
|
||||
y0: 0.0,
|
||||
x1: 0.0,
|
||||
y1: 0.0,
|
||||
});
|
||||
let transparent = s.bool(spread, b"SprT").unwrap_or(false);
|
||||
let (nodes, mask) = self.children(spread, Affine::IDENTITY, 0)?;
|
||||
if mask.is_some() {
|
||||
self.warn("mask layers directly on a page (imported without them)");
|
||||
}
|
||||
spreads.push(Spread { bounds, pages, transparent, nodes });
|
||||
}
|
||||
if spreads.is_empty() {
|
||||
return Err(Error::Malformed("document has no pages"));
|
||||
}
|
||||
if !s.objs(doc, b"MpCh").is_empty() {
|
||||
self.warn("master pages (their content is not placed on the pages that use them)");
|
||||
let mut masters = Vec::new();
|
||||
for master in self.master_ids.clone() {
|
||||
match self.spread(doc, master)? {
|
||||
Some(sp) => masters.push(sp),
|
||||
// Keep the indices of `MasterInstance::master` valid.
|
||||
None => masters.push(Spread {
|
||||
bounds: Rect { x0: 0.0, y0: 0.0, x1: 0.0, y1: 0.0 },
|
||||
pages: Vec::new(),
|
||||
transparent: true,
|
||||
nodes: Vec::new(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
let cmyk_profile = s
|
||||
.obj(root, b"CMYP")
|
||||
.and_then(|p| s.bytes(p, b"Data"))
|
||||
.filter(|b| b.len() <= MAX_PROFILE_BYTES && b.get(16..20) == Some(b"CMYK".as_slice()))
|
||||
.map(<[u8]>::to_vec);
|
||||
let warnings = self.warnings.into_iter().map(|(w, n)| if n > 1 { format!("{w} ({n}×)") } else { w }).collect();
|
||||
Ok(Document { dpi: self.dpi, spreads, saved_by, warnings })
|
||||
Ok((Document { dpi: self.dpi, spreads, masters, saved_by, warnings, cmyk_profile }, self.pixels_left))
|
||||
}
|
||||
|
||||
/// The children of `parent`, and the pixel mask a mask layer among them puts on the parent.
|
||||
fn children(&mut self, parent: ObjId, world: Affine, depth: usize) -> Result<(Vec<Node>, Option<Image>), Error> {
|
||||
/// A spread (`Sprd`) of the document `doc`, or of its master spreads.
|
||||
fn spread(&mut self, doc: ObjId, spread: ObjId) -> Result<Option<Spread>, Error> {
|
||||
let s = self.s;
|
||||
if !s.is(spread, b"Sprd") {
|
||||
self.warn("an unknown kind of page");
|
||||
return Ok(None);
|
||||
}
|
||||
let pages = pages(s, spread);
|
||||
let default_size = s.floats::<2>(doc, b"DfSz").filter(|[w, h]| *w > 0.0 && *h > 0.0).map(|[w, h]| Rect { x0: 0.0, y0: 0.0, x1: w, y1: h });
|
||||
let bounds = s.floats::<4>(spread, b"SprB").map(rect).or_else(|| pages.iter().copied().reduce(union)).or(default_size).unwrap_or(Rect {
|
||||
x0: 0.0,
|
||||
y0: 0.0,
|
||||
x1: 0.0,
|
||||
y1: 0.0,
|
||||
});
|
||||
let transparent = s.bool(spread, b"SprT").unwrap_or(false);
|
||||
let (nodes, mask) = self.children(spread, self.base, 0)?;
|
||||
if mask.is_some() {
|
||||
self.warn("mask layers directly on a page (imported without them)");
|
||||
}
|
||||
Ok(Some(Spread { bounds, pages, transparent, nodes }))
|
||||
}
|
||||
|
||||
/// The children of `parent`, and the mask layer among them that masks the parent (with its
|
||||
/// placement), read later over just what it masks.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn children(&mut self, parent: ObjId, world: Affine, depth: usize) -> Result<(Vec<Node>, Option<(ObjId, Affine)>), Error> {
|
||||
let s = self.s;
|
||||
let mut out = Vec::new();
|
||||
let mut mask = None;
|
||||
@@ -391,10 +630,10 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
continue;
|
||||
}
|
||||
let local = s.floats::<6>(c, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY);
|
||||
match crate::raster::mask(self, c, local.then(world))? {
|
||||
Some(m) if mask.is_none() => mask = Some(m),
|
||||
Some(_) => self.warn("several pixel masks on one layer (only the first is used)"),
|
||||
None => {}
|
||||
if mask.is_none() {
|
||||
mask = Some((c, local.then(world)));
|
||||
} else {
|
||||
self.warn("several pixel masks on one layer (only the first is used)");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -442,14 +681,28 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
self.kind(id, class, world, parent)?
|
||||
};
|
||||
// A compound's children are its operands, already merged into its outline.
|
||||
let (children, child_mask) = if class == Tag::of(b"Comp") { (Vec::new(), None) } else { self.children(id, world, depth)? };
|
||||
let (mask, attached_mask) = self.attached_masks(id, world)?;
|
||||
let (mut children, child_mask) = if class == Tag::of(b"Comp") { (Vec::new(), None) } else { self.children(id, world, depth)? };
|
||||
if let Some(embedded) = self.pending.take() {
|
||||
children.extend(embedded);
|
||||
}
|
||||
// Pixel masks are read only over what they mask (they can span a whole spread at print
|
||||
// resolution, a gigapixel).
|
||||
let over = match (kind_bounds(&kind), nodes_bounds(&children)) {
|
||||
(Some(a), Some(b)) => Some(union(a, b)),
|
||||
(a, b) => a.or(b),
|
||||
};
|
||||
let child_mask = match child_mask {
|
||||
Some((c, at)) => crate::raster::mask(self, c, at, over)?,
|
||||
None => None,
|
||||
};
|
||||
let (mask, attached_mask) = self.attached_masks(id, world, over)?;
|
||||
let pixel_mask = attached_mask.or(child_mask);
|
||||
self.active.remove(&id);
|
||||
if s.objs(id, b"FiEf").iter().any(|e| s.bool(*e, b"Enab") != Some(false)) {
|
||||
self.warn("layer effects (shadows, glows, outlines, bevels…)");
|
||||
}
|
||||
Ok(Some(Node { class, name, visible, locked, opacity, blend, kind, mask, pixel_mask, children }))
|
||||
let mut node = Node { class, name, visible, locked, opacity, blend, kind, mask, pixel_mask, effects: Vec::new(), children };
|
||||
// Gradient overlays are laid out over the node's bounds.
|
||||
let bounds = nodes_bounds(std::slice::from_ref(&node));
|
||||
node.effects = self.effects(id, world, bounds);
|
||||
Ok(Some(node))
|
||||
}
|
||||
|
||||
fn kind(&mut self, id: ObjId, class: Tag, world: Affine, parent: Affine) -> Result<Kind, Error> {
|
||||
@@ -471,6 +724,13 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
let (fills, strokes) = paint::node_paint(self, id, world);
|
||||
Kind::Shape { path: local.transformed(world), fills, strokes, even_odd }
|
||||
}
|
||||
b"TxtT" => match crate::text::table(self, id, world) {
|
||||
Some(t) => Kind::Table(t),
|
||||
None => {
|
||||
self.warn("tables that could not be read");
|
||||
Kind::Unsupported
|
||||
}
|
||||
},
|
||||
b"TxtA" | b"TxtF" | b"TxtC" => match crate::text::read(self, id, world) {
|
||||
Some(t) => Kind::Text(t),
|
||||
None => {
|
||||
@@ -482,15 +742,35 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
Some(img) => Kind::Image(img),
|
||||
None => Kind::Unsupported,
|
||||
},
|
||||
b"EmbN" => match crate::raster::embedded(self, id, world)? {
|
||||
Some(img) => {
|
||||
self.warn("embedded documents and symbols (imported as pictures of them)");
|
||||
Kind::Image(img)
|
||||
b"MPIN" => {
|
||||
// `SLnk` links the instance with the master spread it shows.
|
||||
let linked = s.obj(id, b"SLnk").map(|l| s.objs(l, b"ILOb")).unwrap_or_default();
|
||||
let master = linked.iter().find_map(|o| self.master_ids.iter().position(|m| m == o));
|
||||
let at = |t: &[u8; 4], default: usize| s.int(id, t).and_then(|v| usize::try_from(v).ok()).unwrap_or(default).min(4096);
|
||||
match master {
|
||||
Some(master) => Kind::MasterInstance { master, page: at(b"PgOf", 0), master_page: at(b"MPOf", 0), count: at(b"PgCt", 1) },
|
||||
None => {
|
||||
self.warn("master page instances without their master");
|
||||
Kind::Unsupported
|
||||
}
|
||||
}
|
||||
None => {
|
||||
self.warn("embedded documents and symbols without a cached picture");
|
||||
Kind::Unsupported
|
||||
}
|
||||
b"EmbN" => match self.embedded_document(id, world) {
|
||||
// The embedded document itself, as editable content.
|
||||
Some(nodes) => {
|
||||
self.pending = Some(nodes);
|
||||
Kind::Group
|
||||
}
|
||||
None => match crate::raster::embedded(self, id, world)? {
|
||||
Some(img) => {
|
||||
self.warn("embedded documents and symbols (imported as pictures of them)");
|
||||
Kind::Image(img)
|
||||
}
|
||||
None => {
|
||||
self.warn("embedded documents and symbols without a cached picture");
|
||||
Kind::Unsupported
|
||||
}
|
||||
},
|
||||
},
|
||||
b"FRst" => {
|
||||
// A fill layer paints its whole bitmap area; its gradient lives in page space.
|
||||
@@ -515,9 +795,89 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The enabled layer effects (`FiEf`) of node `id`. The angle convention was measured on the
|
||||
/// public MIT fx fixture (Patchy): an angle of π/2 with offset 10 shadows 10 below.
|
||||
fn effects(&mut self, id: ObjId, world: Affine, bounds: Option<Rect>) -> Vec<Effect> {
|
||||
let s = self.s;
|
||||
let mut out = Vec::new();
|
||||
for e in s.objs(id, b"FiEf").into_iter().take(64) {
|
||||
if s.bool(e, b"Enab") == Some(false) {
|
||||
continue;
|
||||
}
|
||||
// "Scale with object": lengths follow the node's transform; otherwise they are absolute.
|
||||
let k = if s.bool(e, b"SclO") == Some(true) { world.scale() } else { 1.0 };
|
||||
let len = |t: &[u8; 4]| s.f64(e, t).filter(|v| v.is_finite() && *v >= 0.0 && *v < 1e6).unwrap_or(0.0) * k;
|
||||
let opacity = s.f64(e, b"Opac").filter(|v| v.is_finite()).unwrap_or(1.0).clamp(0.0, 1.0);
|
||||
let angle = s.f64(e, b"Angl").filter(|v| v.is_finite()).unwrap_or(0.0);
|
||||
let color = s.obj(e, b"Colr").and_then(|c| crate::paint::color(self, c));
|
||||
let class = s.class(e).map(|c| c.0.to_be_bytes());
|
||||
match (class.as_ref(), color) {
|
||||
(Some(b"Shad"), Some(color)) => out.push(Effect::DropShadow { color, opacity, radius: len(b"Radi"), distance: len(b"Offs"), angle }),
|
||||
(Some(b"InnS"), Some(color)) => out.push(Effect::InnerShadow { color, opacity, radius: len(b"Radi"), distance: len(b"Offs"), angle }),
|
||||
(Some(b"OutG"), Some(color)) => out.push(Effect::OuterGlow { color, opacity, radius: len(b"Radi") }),
|
||||
(Some(b"Strk"), Some(color)) if s.enumeration(e, b"Ftyp").is_none_or(|(t, _)| t == 0) => {
|
||||
let align = s.enumeration(e, b"Alig").map_or(0, |(a, _)| a);
|
||||
out.push(Effect::Outline { color, opacity, width: len(b"Radi"), align });
|
||||
}
|
||||
(Some(b"Strk"), _) => self.warn("gradient outline effects (left out)"),
|
||||
(Some(b"BevE" | b"EmbE"), _) => {
|
||||
// No radius, no bevel (as Affinity draws it).
|
||||
let size = len(b"Radi");
|
||||
if size > 0.0 {
|
||||
let white = crate::paint::Color::Rgb { r: 1.0, g: 1.0, b: 1.0, a: 1.0 };
|
||||
let black = crate::paint::Color::Rgb { r: 0.0, g: 0.0, b: 0.0, a: 1.0 };
|
||||
let highlight = s.obj(e, b"HiCl").and_then(|c| crate::paint::color(self, c)).unwrap_or(white);
|
||||
let shadow = s.obj(e, b"ShCl").and_then(|c| crate::paint::color(self, c)).unwrap_or(black);
|
||||
let shadow_opacity = s.f64(e, b"ShOp").filter(|v| v.is_finite()).unwrap_or(0.75).clamp(0.0, 1.0);
|
||||
let depth = s.f64(e, b"Dept").filter(|v| v.is_finite()).unwrap_or(5.0);
|
||||
let azimuth = s.f64(e, b"Azim").filter(|v| v.is_finite()).unwrap_or(std::f64::consts::FRAC_PI_4 * 3.0);
|
||||
let inner = s.enumeration(e, b"Beve").is_none_or(|(k, _)| k == 0);
|
||||
out.push(Effect::Bevel { size, depth, angle: azimuth, highlight, highlight_opacity: opacity, shadow, shadow_opacity, inner });
|
||||
}
|
||||
}
|
||||
(Some(b"ColO"), Some(color)) => {
|
||||
let blend = self.effect_blend(e);
|
||||
out.push(Effect::ColorOverlay { color, opacity, blend });
|
||||
}
|
||||
(Some(b"GrdO"), _) => {
|
||||
let blend = self.effect_blend(e);
|
||||
// The gradient's unit space spans the node's bounding box from -1 to 1, centred
|
||||
// on it (y down): checked against the stored previews of real documents.
|
||||
let space =
|
||||
bounds.map(|b| Affine([(b.x1 - b.x0) / 2.0, 0.0, 0.0, (b.y1 - b.y0) / 2.0, (b.x0 + b.x1) / 2.0, (b.y0 + b.y1) / 2.0]));
|
||||
match (s.obj(e, b"GrFl"), space) {
|
||||
(Some(fill), Some(space)) => match paint::descriptor(self, fill, space) {
|
||||
Some(Paint::Gradient(gradient)) => out.push(Effect::GradientOverlay { gradient, opacity, blend }),
|
||||
Some(Paint::Solid(color)) => out.push(Effect::ColorOverlay { color, opacity, blend }),
|
||||
_ => {}
|
||||
},
|
||||
// Nothing visible to lay it over.
|
||||
(Some(_), None) => {}
|
||||
(None, _) => self.warn("gradient overlay effects that could not be read (left out)"),
|
||||
}
|
||||
}
|
||||
(Some(b"ColO"), None) => self.warn("colour overlay effects that could not be read (left out)"),
|
||||
(Some(b"Gaus"), _) => self.warn("blur effects (left out)"),
|
||||
_ => self.warn("layer effects of other kinds (left out)"),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A layer effect's blend mode (`BlnM`); unknown modes are Normal (warned).
|
||||
fn effect_blend(&mut self, e: ObjId) -> Blend {
|
||||
match self.s.enumeration(e, b"BlnM") {
|
||||
None => Blend::Normal,
|
||||
Some((i, v)) => Blend::from_enum(i, v).unwrap_or_else(|| {
|
||||
self.warn("an unknown blend mode (imported as Normal)");
|
||||
Blend::Normal
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Vector shapes attached to a node (`AdCh`) mask it: it shows only inside their outlines;
|
||||
/// an attached mask layer is a pixel mask. Adjustments attached the same way are reported.
|
||||
fn attached_masks(&mut self, id: ObjId, world: Affine) -> Result<(Option<Path>, Option<Image>), Error> {
|
||||
fn attached_masks(&mut self, id: ObjId, world: Affine, over: Option<Rect>) -> Result<(Option<Path>, Option<Image>), Error> {
|
||||
let s = self.s;
|
||||
let attached = s.objs(id, b"AdCh");
|
||||
let mut mask: Option<Path> = None;
|
||||
@@ -534,7 +894,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
}
|
||||
} else if class == Tag::of(b"MRst") {
|
||||
let local = s.floats::<6>(a, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY).then(world);
|
||||
match crate::raster::mask(self, a, local)? {
|
||||
match crate::raster::mask(self, a, local, over)? {
|
||||
Some(m) if pixels.is_none() => pixels = Some(m),
|
||||
Some(_) => self.warn("several pixel masks on one layer (only the first is used)"),
|
||||
None => {}
|
||||
@@ -546,6 +906,68 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
Ok((mask, pixels))
|
||||
}
|
||||
|
||||
/// The content of an Affinity document embedded in this one (`EmbN` whose `EmbC` entry holds
|
||||
/// `EmDc`, four more bytes and a complete Affinity document): its first spread's nodes,
|
||||
/// placed so the centre of its page, first artboard or content is the node's origin.
|
||||
fn embedded_document(&mut self, id: ObjId, world: Affine) -> Option<Vec<Node>> {
|
||||
let s = self.s;
|
||||
let entry = s.obj(id, b"Bitm").and_then(|b| s.obj(b, b"EmCn")).and_then(|c| s.entry(c, b"EmbC"))?.to_string();
|
||||
if entry.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if self.depth >= MAX_EMBED_DEPTH {
|
||||
self.warn("embedded documents nested more than four deep");
|
||||
return None;
|
||||
}
|
||||
let data = self.archive.read(&entry).ok()?;
|
||||
let inner = data.strip_prefix(b"EmDc").and_then(|d| d.get(4..))?;
|
||||
if !crate::is_affinity(inner) {
|
||||
return None;
|
||||
}
|
||||
// A first reading finds its page and resolution, the second places it.
|
||||
let probe = match read_at(inner, self.archive.remaining(), self.pixels_left, Affine::IDENTITY, self.depth + 1) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
self.warn(format!("embedded documents that could not be read ({e})"));
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let used = probe.extracted;
|
||||
let spread = probe.document.spreads.first()?;
|
||||
// `PBBx` 2 places the content's own bounds (as with a cached picture); otherwise its page.
|
||||
let content = s.obj(id, b"Bitm").and_then(|b| s.enumeration(b, b"PBBx")).is_some_and(|(k, _)| k == 2);
|
||||
let first_board = spread.nodes.iter().find_map(|n| if let Kind::Artboard { rect, .. } = &n.kind { Some(*rect) } else { None });
|
||||
let page = if content { None } else { spread.pages.first().copied().or(first_board) };
|
||||
let page = page.or_else(|| nodes_bounds(&spread.nodes)).unwrap_or(spread.bounds);
|
||||
// Its document pixels are the node's units, whatever either document's resolution.
|
||||
let k = 1.0;
|
||||
let (cx, cy) = ((page.x0 + page.x1) / 2.0, (page.y0 + page.y1) / 2.0);
|
||||
let base = Affine([k, 0.0, 0.0, k, -cx * k, -cy * k]).then(world);
|
||||
if !base.is_finite() {
|
||||
self.warn("embedded documents with an invalid placement");
|
||||
return None;
|
||||
}
|
||||
if self.archive.charge(used).is_err() {
|
||||
self.warn("embedded documents beyond the size limit (left out)");
|
||||
return None;
|
||||
}
|
||||
let placed = read_at(inner, self.archive.remaining(), self.pixels_left, base, self.depth + 1).ok()?;
|
||||
// The probe's pixels were only looked at; the placed content's stay.
|
||||
self.pixels_left = placed.pixels_left;
|
||||
let doc = placed.document;
|
||||
if self.archive.charge(placed.extracted).is_err() {
|
||||
self.warn("embedded documents beyond the size limit (left out)");
|
||||
return None;
|
||||
}
|
||||
for w in doc.warnings {
|
||||
self.warn(format!("{w} [in embedded documents]"));
|
||||
}
|
||||
if doc.spreads.len() > 1 {
|
||||
self.warn("embedded documents with several pages (the first is used)");
|
||||
}
|
||||
doc.spreads.into_iter().next().map(|s| s.nodes)
|
||||
}
|
||||
|
||||
fn artboard(&mut self, id: ObjId, world: Affine) -> Kind {
|
||||
let s = self.s;
|
||||
let Some(b) = s.floats::<4>(id, b"ShpB").map(rect).filter(|b| b.x1 > b.x0 && b.y1 > b.y0) else {
|
||||
@@ -580,6 +1002,64 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The bounds of what visible `nodes` draw (control points of outlines, corners of images, frames
|
||||
/// and tables), in document pixels.
|
||||
pub fn nodes_bounds(nodes: &[Node]) -> Option<Rect> {
|
||||
fn walk(nodes: &[Node], depth: usize, out: &mut Option<Rect>) {
|
||||
if depth > MAX_TREE_DEPTH {
|
||||
return;
|
||||
}
|
||||
for n in nodes.iter().filter(|n| n.visible) {
|
||||
kind_points(&n.kind, &mut |p| add_point(out, p));
|
||||
walk(&n.children, depth + 1, out);
|
||||
}
|
||||
}
|
||||
let mut out = None;
|
||||
walk(nodes, 0, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// The bounds of what one node's own kind draws (not its children).
|
||||
pub(crate) fn kind_bounds(kind: &Kind) -> Option<Rect> {
|
||||
let mut out = None;
|
||||
kind_points(kind, &mut |p| add_point(&mut out, p));
|
||||
out
|
||||
}
|
||||
|
||||
fn add_point(out: &mut Option<Rect>, p: Point) {
|
||||
if p.x.is_finite() && p.y.is_finite() {
|
||||
let r = Rect { x0: p.x, y0: p.y, x1: p.x, y1: p.y };
|
||||
*out = Some(out.map_or(r, |o| union(o, r)));
|
||||
}
|
||||
}
|
||||
|
||||
fn kind_points(kind: &Kind, add: &mut dyn FnMut(Point)) {
|
||||
let corners = |r: Rect, m: Affine| [(r.x0, r.y0), (r.x1, r.y0), (r.x1, r.y1), (r.x0, r.y1)].map(|(x, y)| m.apply(Point { x, y }));
|
||||
match kind {
|
||||
Kind::Shape { path, .. } | Kind::Artboard { path, .. } => {
|
||||
for sp in &path.subpaths {
|
||||
add(sp.start);
|
||||
for seg in &sp.segments {
|
||||
seg.iter().for_each(|p| add(*p));
|
||||
}
|
||||
}
|
||||
}
|
||||
Kind::Image(img) => {
|
||||
corners(Rect { x0: 0.0, y0: 0.0, x1: f64::from(img.width), y1: f64::from(img.height) }, img.transform).into_iter().for_each(add)
|
||||
}
|
||||
Kind::Text(t) => match t.frame {
|
||||
Some(f) => corners(f, t.transform).into_iter().for_each(add),
|
||||
None => add(t.transform.apply(t.anchor)),
|
||||
},
|
||||
Kind::Table(t) => {
|
||||
if let (Some(&x0), Some(&x1), Some(&y0), Some(&y1)) = (t.columns.first(), t.columns.last(), t.rows.first(), t.rows.last()) {
|
||||
corners(Rect { x0, y0, x1, y1 }, t.transform).into_iter().for_each(add);
|
||||
}
|
||||
}
|
||||
Kind::Layer | Kind::Group | Kind::Unsupported | Kind::MasterInstance { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Page rectangles: `SpMd.PagR[].rctp` (Publisher 2, Affinity 3) or `SprB` split into `PagC`
|
||||
/// equal pages (Publisher 1 facing spreads).
|
||||
fn pages(s: &Stream, spread: ObjId) -> Vec<Rect> {
|
||||
|
||||
@@ -0,0 +1,543 @@
|
||||
//! Colour and gradient overlays. VectorCraft has no such effect, so an overlay is baked into what
|
||||
//! its node paints: fill, stroke and text colours, and pixels. The overlay lies over the node's
|
||||
//! own paint in its blend mode at its opacity, inside the node's alpha (which it keeps).
|
||||
//!
|
||||
//! Blend modes follow the W3C Compositing and Blending formulas; the modes PDF lacks (Vivid
|
||||
//! Light, Linear Light, Pin Light, Hard Mix, Add, Subtract, Average, Negation, Reflect, Glow,
|
||||
//! Darker/Lighter Color) use their usual definitions.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use crate::model::{Affine, Blend, Effect, Image, Kind, Node, Pixels, Point};
|
||||
use crate::paint::{Color, Gradient, GradientKind, Paint, Stop, Stroke};
|
||||
|
||||
/// Deepest subtree an overlay is baked into (the reader bounds trees well below this).
|
||||
const MAX_DEPTH: usize = 256;
|
||||
|
||||
/// Most pixels an overlay is baked into per image.
|
||||
const MAX_PIXELS: usize = 64 << 20;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Source<'a> {
|
||||
Solid(Color),
|
||||
Gradient(&'a Gradient),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Overlay<'a> {
|
||||
source: Source<'a>,
|
||||
opacity: f64,
|
||||
blend: Blend,
|
||||
}
|
||||
|
||||
/// `node` with its colour and gradient overlays baked in (borrowed unchanged when it has none).
|
||||
/// `warn` hears what could only be approximated.
|
||||
pub fn apply<'a>(node: &'a Node, warn: &mut dyn FnMut(&str)) -> Cow<'a, Node> {
|
||||
let overlays: Vec<Overlay> = node
|
||||
.effects
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
Effect::ColorOverlay { color, opacity, blend } => Some(Overlay { source: Source::Solid(*color), opacity: *opacity, blend: *blend }),
|
||||
Effect::GradientOverlay { gradient, opacity, blend } => {
|
||||
Some(Overlay { source: Source::Gradient(gradient), opacity: *opacity, blend: *blend })
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.filter(|o| o.opacity > 0.0)
|
||||
.collect();
|
||||
if overlays.is_empty() {
|
||||
return Cow::Borrowed(node);
|
||||
}
|
||||
let mut out = node.clone();
|
||||
out.effects.retain(|e| !matches!(e, Effect::ColorOverlay { .. } | Effect::GradientOverlay { .. }));
|
||||
for o in &overlays {
|
||||
if matches!(o.blend, Blend::Erase | Blend::PassThrough) {
|
||||
warn("overlay effects in a blend mode VectorCraft doesn't have (imported as Normal)");
|
||||
}
|
||||
bake_node(&mut out, o, 0, warn);
|
||||
}
|
||||
Cow::Owned(out)
|
||||
}
|
||||
|
||||
fn bake_node(node: &mut Node, o: &Overlay, depth: usize, warn: &mut dyn FnMut(&str)) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
match &mut node.kind {
|
||||
Kind::Shape { fills, strokes, .. } => {
|
||||
for f in fills.iter_mut() {
|
||||
*f = paint(f, o, warn);
|
||||
}
|
||||
strokes.iter_mut().for_each(|s| stroke(s, o, warn));
|
||||
}
|
||||
Kind::Artboard { background, strokes, .. } => {
|
||||
for f in background.iter_mut() {
|
||||
*f = paint(f, o, warn);
|
||||
}
|
||||
strokes.iter_mut().for_each(|s| stroke(s, o, warn));
|
||||
}
|
||||
Kind::Text(t) => {
|
||||
for r in &mut t.runs {
|
||||
r.fill = paint(&r.fill, o, warn);
|
||||
if let Some(s) = &mut r.stroke {
|
||||
stroke(s, o, warn);
|
||||
}
|
||||
}
|
||||
}
|
||||
Kind::Table(t) => {
|
||||
for c in &mut t.cells {
|
||||
for r in &mut c.runs {
|
||||
r.fill = paint(&r.fill, o, warn);
|
||||
}
|
||||
}
|
||||
for s in t.vertical.iter_mut().chain(t.horizontal.iter_mut()).flatten() {
|
||||
stroke(s, o, warn);
|
||||
}
|
||||
}
|
||||
Kind::Image(img) => {
|
||||
if !pixels(img, o) {
|
||||
warn("overlay effects on images too large to recolour (left out)");
|
||||
}
|
||||
}
|
||||
Kind::Layer | Kind::Group | Kind::Unsupported | Kind::MasterInstance { .. } => {}
|
||||
}
|
||||
for e in &mut node.effects {
|
||||
match e {
|
||||
Effect::Outline { color, .. } => *color = over(*color, o.source.at_solid(), o),
|
||||
Effect::DropShadow { .. } | Effect::InnerShadow { .. } | Effect::OuterGlow { .. } | Effect::Bevel { .. } => {}
|
||||
Effect::ColorOverlay { .. } | Effect::GradientOverlay { .. } => {}
|
||||
}
|
||||
}
|
||||
for c in &mut node.children {
|
||||
bake_node(c, o, depth + 1, warn);
|
||||
}
|
||||
}
|
||||
|
||||
fn stroke(s: &mut Stroke, o: &Overlay, warn: &mut dyn FnMut(&str)) {
|
||||
s.paint = paint(&s.paint, o, warn);
|
||||
}
|
||||
|
||||
/// A paint with the overlay over it.
|
||||
fn paint(p: &Paint, o: &Overlay, warn: &mut dyn FnMut(&str)) -> Paint {
|
||||
match (p, o.source) {
|
||||
(Paint::None, _) => Paint::None,
|
||||
(Paint::Solid(c), Source::Solid(s)) => Paint::Solid(over(*c, s, o)),
|
||||
(Paint::Gradient(g), Source::Solid(s)) => {
|
||||
Paint::Gradient(Gradient { stops: g.stops.iter().map(|st| Stop { color: over(st.color, s, o), ..*st }).collect(), ..g.clone() })
|
||||
}
|
||||
// Over one colour, the result is the overlay's gradient with each stop laid over it.
|
||||
(Paint::Solid(c), Source::Gradient(g)) => {
|
||||
Paint::Gradient(Gradient { stops: g.stops.iter().map(|st| Stop { color: over(*c, st.color, o), ..*st }).collect(), ..g.clone() })
|
||||
}
|
||||
(Paint::Gradient(base), Source::Gradient(g)) => {
|
||||
if !(o.blend == Blend::Normal && o.opacity >= 1.0) {
|
||||
warn("gradient overlays on gradients (approximated)");
|
||||
}
|
||||
// Exact when the overlay covers it; otherwise over the base gradient's average colour.
|
||||
let avg = average(base);
|
||||
Paint::Gradient(Gradient { stops: g.stops.iter().map(|st| Stop { color: over(avg, st.color, o), ..*st }).collect(), ..g.clone() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Source<'_> {
|
||||
/// One colour for the source (a gradient's average) where only one fits.
|
||||
fn at_solid(&self) -> Color {
|
||||
match self {
|
||||
Source::Solid(c) => *c,
|
||||
Source::Gradient(g) => average(g),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn average(g: &Gradient) -> Color {
|
||||
let n = g.stops.len().max(1) as f64;
|
||||
let (mut r, mut gr, mut b, mut a) = (0.0, 0.0, 0.0, 0.0);
|
||||
for s in &g.stops {
|
||||
let [sr, sg, sb] = rgb(s.color);
|
||||
r += sr;
|
||||
gr += sg;
|
||||
b += sb;
|
||||
a += s.color.alpha();
|
||||
}
|
||||
Color::Rgb { r: r / n, g: gr / n, b: b / n, a: a / n }
|
||||
}
|
||||
|
||||
/// `source` laid over `base` in the overlay's mode and opacity; `base`'s alpha is kept.
|
||||
fn over(base: Color, source: Color, o: &Overlay) -> Color {
|
||||
let k = (o.opacity * source.alpha()).clamp(0.0, 1.0);
|
||||
let alpha = base.alpha();
|
||||
// Normal mixes within the colour model when both share it, so CMYK stays CMYK.
|
||||
if matches!(o.blend, Blend::Normal | Blend::PassThrough | Blend::Erase) {
|
||||
let mix = |a: f64, b: f64| a + (b - a) * k;
|
||||
match (base, source) {
|
||||
(Color::Cmyk { c, m, y, k: kk, .. }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2, .. }) => {
|
||||
return Color::Cmyk { c: mix(c, c2), m: mix(m, m2), y: mix(y, y2), k: mix(kk, k2), a: alpha };
|
||||
}
|
||||
(Color::Gray { v, .. }, Color::Gray { v: v2, .. }) => return Color::Gray { v: mix(v, v2), a: alpha },
|
||||
(_, s) if k >= 1.0 => return with_alpha(s, alpha),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let b = rgb(base);
|
||||
let s = rgb(source);
|
||||
let blended = blend(o.blend, b, s);
|
||||
let [r, g, bl] = [0, 1, 2].map(|i| b[i] + (blended[i] - b[i]) * k);
|
||||
Color::Rgb { r, g, b: bl, a: alpha }
|
||||
}
|
||||
|
||||
fn with_alpha(c: Color, a: f64) -> Color {
|
||||
match c {
|
||||
Color::Rgb { r, g, b, .. } => Color::Rgb { r, g, b, a },
|
||||
Color::Cmyk { c, m, y, k, .. } => Color::Cmyk { c, m, y, k, a },
|
||||
Color::Gray { v, .. } => Color::Gray { v, a },
|
||||
Color::Lab { l, a: la, b, .. } => Color::Lab { l, a: la, b, alpha: a },
|
||||
}
|
||||
}
|
||||
|
||||
/// sRGB components (0..1) of a colour. CMYK uses the profile-free formula (the engine shows the
|
||||
/// node's other CMYK colours through the document's profile), Lab its D50 sRGB colour.
|
||||
fn rgb(c: Color) -> [f64; 3] {
|
||||
let f = |v: f64| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 };
|
||||
match c {
|
||||
Color::Rgb { r, g, b, .. } => [f(r), f(g), f(b)],
|
||||
Color::Gray { v, .. } => [f(v); 3],
|
||||
Color::Cmyk { c, m, y, k, .. } => {
|
||||
let w = 1.0 - f(k);
|
||||
[(1.0 - f(c)) * w, (1.0 - f(m)) * w, (1.0 - f(y)) * w]
|
||||
}
|
||||
Color::Lab { l, a, b, .. } => lab_to_srgb(l, a, b).map(f),
|
||||
}
|
||||
}
|
||||
|
||||
/// CIE L*a*b* (D50) to sRGB, through XYZ and the Bradford adaptation to D65.
|
||||
fn lab_to_srgb(l: f64, a: f64, b: f64) -> [f64; 3] {
|
||||
let finite = |v: f64| if v.is_finite() { v } else { 0.0 };
|
||||
let (l, a, b) = (finite(l), finite(a), finite(b));
|
||||
let fy = (l + 16.0) / 116.0;
|
||||
let (fx, fz) = (fy + a / 500.0, fy - b / 200.0);
|
||||
let inv = |t: f64| if t > 6.0 / 29.0 { t * t * t } else { 3.0 * (6.0f64 / 29.0).powi(2) * (t - 4.0 / 29.0) };
|
||||
// D50 white.
|
||||
let (x, y, z) = (0.964_22 * inv(fx), inv(fy), 0.825_21 * inv(fz));
|
||||
// XYZ (D50) to linear sRGB, Bradford-adapted.
|
||||
let r = 3.133_856 * x - 1.616_867 * y - 0.490_615 * z;
|
||||
let g = -0.978_768 * x + 1.916_142 * y + 0.033_454 * z;
|
||||
let bl = 0.071_945 * x - 0.228_991 * y + 1.405_243 * z;
|
||||
let gamma = |v: f64| if v <= 0.003_130_8 { 12.92 * v } else { 1.055 * v.max(0.0).powf(1.0 / 2.4) - 0.055 };
|
||||
[gamma(r), gamma(g), gamma(bl)]
|
||||
}
|
||||
|
||||
/// Bake the overlay into an image's pixels. False if they couldn't be read or are too many.
|
||||
fn pixels(img: &mut Image, o: &Overlay) -> bool {
|
||||
let Some(n) = (img.width as usize).checked_mul(img.height as usize).filter(|n| *n <= MAX_PIXELS) else { return false };
|
||||
let Some(len) = n.checked_mul(4) else { return false };
|
||||
let mut px = match &img.pixels {
|
||||
Pixels::Rgba8(p) => match p.get(..len) {
|
||||
Some(p) => p.to_vec(),
|
||||
None => return false,
|
||||
},
|
||||
Pixels::Encoded(bytes) => match image::load_from_memory(bytes) {
|
||||
Ok(d) if d.width() == img.width && d.height() == img.height => d.to_rgba8().into_raw(),
|
||||
_ => return false,
|
||||
},
|
||||
// An overlay is blended in RGB: CMYK pixels take the profile-free formula here.
|
||||
Pixels::Cmyka8(p) => match p.get(..n.saturating_mul(5)) {
|
||||
Some(p) if p.len() == n * 5 => p
|
||||
.as_chunks::<5>()
|
||||
.0
|
||||
.iter()
|
||||
.flat_map(|[c, m, y, k, a]| {
|
||||
let w = 255 - u16::from(*k);
|
||||
let ch = |v: u8| ((u16::from(255 - v) * w + 127) / 255) as u8;
|
||||
[ch(*c), ch(*m), ch(*y), *a]
|
||||
})
|
||||
.collect(),
|
||||
_ => return false,
|
||||
},
|
||||
};
|
||||
let width = img.width.max(1) as usize;
|
||||
// A solid source is converted once; a gradient is sampled at each pixel's centre.
|
||||
let solid = match o.source {
|
||||
Source::Solid(c) => Some((rgb(c), c.alpha())),
|
||||
Source::Gradient(_) => None,
|
||||
};
|
||||
let to_unit = match o.source {
|
||||
Source::Gradient(g) => match invert(g.transform) {
|
||||
Some(m) => Some((img.transform.then(m), g)),
|
||||
None => return false,
|
||||
},
|
||||
Source::Solid(_) => None,
|
||||
};
|
||||
for (i, p) in px.as_chunks_mut::<4>().0.iter_mut().enumerate() {
|
||||
if p[3] == 0 {
|
||||
continue;
|
||||
}
|
||||
let (s, sa) = match (solid, &to_unit) {
|
||||
(Some(s), _) => s,
|
||||
(None, Some((m, g))) => {
|
||||
let q = m.apply(Point { x: (i % width) as f64 + 0.5, y: (i / width) as f64 + 0.5 });
|
||||
let c = sample(g, q);
|
||||
(rgb(c), c.alpha())
|
||||
}
|
||||
(None, None) => continue,
|
||||
};
|
||||
let k = (o.opacity * sa).clamp(0.0, 1.0);
|
||||
let b = [p[0], p[1], p[2]].map(|v| f64::from(v) / 255.0);
|
||||
let m = blend(o.blend, b, s);
|
||||
for c in 0..3 {
|
||||
p[c] = ((b[c] + (m[c] - b[c]) * k).clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
}
|
||||
}
|
||||
img.pixels = Pixels::Rgba8(px);
|
||||
true
|
||||
}
|
||||
|
||||
/// The gradient's colour at unit-space point `q`.
|
||||
fn sample(g: &Gradient, q: Point) -> Color {
|
||||
let t = match g.kind {
|
||||
GradientKind::Linear => q.x,
|
||||
GradientKind::Radial => q.x.hypot(q.y),
|
||||
GradientKind::Conical => q.y.atan2(q.x).rem_euclid(std::f64::consts::TAU) / std::f64::consts::TAU,
|
||||
};
|
||||
let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 };
|
||||
let (Some(first), Some(last)) = (g.stops.first(), g.stops.last()) else { return Color::Rgb { r: 0.0, g: 0.0, b: 0.0, a: 0.0 } };
|
||||
if t <= first.offset {
|
||||
return first.color;
|
||||
}
|
||||
for w in g.stops.windows(2) {
|
||||
let [a, b] = [&w[0], &w[1]];
|
||||
if t <= b.offset {
|
||||
let span = b.offset - a.offset;
|
||||
let u = if span > 1e-9 { (t - a.offset) / span } else { 1.0 };
|
||||
// Affinity's midpoint bias (see `Stop::half_point`).
|
||||
let k = 1.0 + 8.0 * (a.bias - 0.5).abs();
|
||||
let u = if a.bias >= 0.5 { u.powf(k) } else { 1.0 - (1.0 - u).powf(k) };
|
||||
let (x, y) = (rgb(a.color), rgb(b.color));
|
||||
let [r, g, bl] = [0, 1, 2].map(|i| x[i] + (y[i] - x[i]) * u);
|
||||
return Color::Rgb { r, g, b: bl, a: a.color.alpha() + (b.color.alpha() - a.color.alpha()) * u };
|
||||
}
|
||||
}
|
||||
last.color
|
||||
}
|
||||
|
||||
fn invert(m: Affine) -> Option<Affine> {
|
||||
let [a, b, c, d, e, f] = m.0;
|
||||
let det = a * d - b * c;
|
||||
if !(det.is_finite() && det.abs() > 1e-12) {
|
||||
return None;
|
||||
}
|
||||
let (ia, ib, ic, id) = (d / det, -b / det, -c / det, a / det);
|
||||
Some(Affine([ia, ib, ic, id, -(ia * e + ic * f), -(ib * e + id * f)]))
|
||||
}
|
||||
|
||||
/// The blend of backdrop `b` and source `s` (sRGB, 0..1).
|
||||
fn blend(mode: Blend, b: [f64; 3], s: [f64; 3]) -> [f64; 3] {
|
||||
use Blend::*;
|
||||
let sep = |f: fn(f64, f64) -> f64| [0, 1, 2].map(|i| f(b[i], s[i]).clamp(0.0, 1.0));
|
||||
match mode {
|
||||
Normal | PassThrough | Erase => s,
|
||||
Darken => sep(f64::min),
|
||||
Lighten => sep(f64::max),
|
||||
Multiply => sep(|b, s| b * s),
|
||||
Screen => sep(screen),
|
||||
ColorBurn => sep(burn),
|
||||
ColorDodge => sep(dodge),
|
||||
Blend::Overlay => sep(|b, s| hard_light(s, b)),
|
||||
HardLight => sep(hard_light),
|
||||
SoftLight => sep(soft_light),
|
||||
Difference => sep(|b, s| (b - s).abs()),
|
||||
Exclusion => sep(|b, s| b + s - 2.0 * b * s),
|
||||
Add => sep(|b, s| b + s),
|
||||
Subtract => sep(|b, s| b - s),
|
||||
VividLight => sep(|b, s| if s <= 0.5 { burn(b, 2.0 * s) } else { dodge(b, 2.0 * s - 1.0) }),
|
||||
LinearLight => sep(|b, s| b + 2.0 * s - 1.0),
|
||||
PinLight => sep(|b, s| if s <= 0.5 { b.min(2.0 * s) } else { b.max(2.0 * s - 1.0) }),
|
||||
HardMix => sep(|b, s| if b + s >= 1.0 { 1.0 } else { 0.0 }),
|
||||
Average => sep(|b, s| (b + s) / 2.0),
|
||||
Negation => sep(|b, s| 1.0 - (1.0 - b - s).abs()),
|
||||
Reflect => sep(reflect),
|
||||
Glow => sep(|b, s| reflect(s, b)),
|
||||
DarkerColor => {
|
||||
if lum(s) < lum(b) {
|
||||
s
|
||||
} else {
|
||||
b
|
||||
}
|
||||
}
|
||||
LighterColor => {
|
||||
if lum(s) > lum(b) {
|
||||
s
|
||||
} else {
|
||||
b
|
||||
}
|
||||
}
|
||||
Hue => set_lum(set_sat(s, sat(b)), lum(b)),
|
||||
Saturation => set_lum(set_sat(b, sat(s)), lum(b)),
|
||||
Blend::Color => set_lum(s, lum(b)),
|
||||
Luminosity => set_lum(b, lum(s)),
|
||||
}
|
||||
}
|
||||
|
||||
fn screen(b: f64, s: f64) -> f64 {
|
||||
b + s - b * s
|
||||
}
|
||||
|
||||
fn burn(b: f64, s: f64) -> f64 {
|
||||
if b >= 1.0 {
|
||||
1.0
|
||||
} else if s <= 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
1.0 - ((1.0 - b) / s).min(1.0)
|
||||
}
|
||||
}
|
||||
|
||||
fn dodge(b: f64, s: f64) -> f64 {
|
||||
if b <= 0.0 {
|
||||
0.0
|
||||
} else if s >= 1.0 {
|
||||
1.0
|
||||
} else {
|
||||
(b / (1.0 - s)).min(1.0)
|
||||
}
|
||||
}
|
||||
|
||||
fn hard_light(b: f64, s: f64) -> f64 {
|
||||
if s <= 0.5 { b * 2.0 * s } else { screen(b, 2.0 * s - 1.0) }
|
||||
}
|
||||
|
||||
fn soft_light(b: f64, s: f64) -> f64 {
|
||||
if s <= 0.5 {
|
||||
b - (1.0 - 2.0 * s) * b * (1.0 - b)
|
||||
} else {
|
||||
let d = if b <= 0.25 { ((16.0 * b - 12.0) * b + 4.0) * b } else { b.sqrt() };
|
||||
b + (2.0 * s - 1.0) * (d - b)
|
||||
}
|
||||
}
|
||||
|
||||
fn reflect(b: f64, s: f64) -> f64 {
|
||||
if s >= 1.0 { 1.0 } else { (b * b / (1.0 - s)).min(1.0) }
|
||||
}
|
||||
|
||||
fn lum(c: [f64; 3]) -> f64 {
|
||||
0.3 * c[0] + 0.59 * c[1] + 0.11 * c[2]
|
||||
}
|
||||
|
||||
fn clip_color(c: [f64; 3]) -> [f64; 3] {
|
||||
let l = lum(c);
|
||||
let n = c[0].min(c[1]).min(c[2]);
|
||||
let x = c[0].max(c[1]).max(c[2]);
|
||||
c.map(|v| {
|
||||
let mut v = v;
|
||||
if n < 0.0 && l - n > 1e-12 {
|
||||
v = l + (v - l) * l / (l - n);
|
||||
}
|
||||
if x > 1.0 && x - l > 1e-12 {
|
||||
v = l + (v - l) * (1.0 - l) / (x - l);
|
||||
}
|
||||
v.clamp(0.0, 1.0)
|
||||
})
|
||||
}
|
||||
|
||||
fn set_lum(c: [f64; 3], l: f64) -> [f64; 3] {
|
||||
let d = l - lum(c);
|
||||
clip_color(c.map(|v| v + d))
|
||||
}
|
||||
|
||||
fn sat(c: [f64; 3]) -> f64 {
|
||||
c[0].max(c[1]).max(c[2]) - c[0].min(c[1]).min(c[2])
|
||||
}
|
||||
|
||||
fn set_sat(c: [f64; 3], s: f64) -> [f64; 3] {
|
||||
let max = c[0].max(c[1]).max(c[2]);
|
||||
let min = c[0].min(c[1]).min(c[2]);
|
||||
if max - min <= 1e-12 {
|
||||
return [0.0; 3];
|
||||
}
|
||||
c.map(|v| (v - min) * s / (max - min))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn solid(c: Color, opacity: f64, blend: Blend) -> Node {
|
||||
Node {
|
||||
class: crate::stream::Tag::of(b"ShpN"),
|
||||
name: String::new(),
|
||||
visible: true,
|
||||
locked: false,
|
||||
opacity: 1.0,
|
||||
blend: Blend::Normal,
|
||||
kind: Kind::Shape {
|
||||
path: Default::default(),
|
||||
fills: vec![Paint::Solid(Color::Rgb { r: 1.0, g: 0.5, b: 0.0, a: 1.0 })],
|
||||
strokes: vec![],
|
||||
even_odd: false,
|
||||
},
|
||||
mask: None,
|
||||
pixel_mask: None,
|
||||
effects: vec![Effect::ColorOverlay { color: c, opacity, blend }],
|
||||
children: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn fill(n: &Node) -> Color {
|
||||
match &n.kind {
|
||||
Kind::Shape { fills, .. } => match fills.first() {
|
||||
Some(Paint::Solid(c)) => *c,
|
||||
other => panic!("{other:?}"),
|
||||
},
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_colour_overlay_replaces_the_fill_and_keeps_its_alpha() {
|
||||
let white = Color::Rgb { r: 1.0, g: 1.0, b: 1.0, a: 1.0 };
|
||||
let n = apply(&solid(white, 1.0, Blend::Normal), &mut |_| {}).into_owned();
|
||||
assert_eq!(fill(&n), white);
|
||||
assert!(n.effects.is_empty(), "the overlay is baked, not kept");
|
||||
// Half way, mixed in RGB.
|
||||
let n = apply(&solid(white, 0.5, Blend::Normal), &mut |_| {}).into_owned();
|
||||
assert_eq!(fill(&n), Color::Rgb { r: 1.0, g: 0.75, b: 0.5, a: 1.0 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_modes_follow_their_formulas() {
|
||||
let b = [0.25, 0.5, 1.0];
|
||||
assert_eq!(blend(Blend::Multiply, b, [0.5; 3]), [0.125, 0.25, 0.5]);
|
||||
assert_eq!(blend(Blend::Screen, b, [0.5; 3]), [0.625, 0.75, 1.0]);
|
||||
assert_eq!(blend(Blend::VividLight, [0.5; 3], [0.75; 3]), [1.0; 3]);
|
||||
assert_eq!(blend(Blend::HardMix, [0.5; 3], [0.6; 3]), [1.0; 3]);
|
||||
// Color keeps the backdrop's luminosity with the source's hue and saturation.
|
||||
let c = blend(Blend::Color, [0.5; 3], [1.0, 0.0, 0.0]);
|
||||
assert!((lum(c) - 0.5).abs() < 1e-9 && c[0] > c[1] && (c[1] - c[2]).abs() < 1e-9, "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlays_recolour_pixels_inside_their_alpha() {
|
||||
let mut n = solid(Color::Rgb { r: 0.0, g: 0.0, b: 1.0, a: 1.0 }, 1.0, Blend::Normal);
|
||||
n.kind = Kind::Image(Image { width: 2, height: 1, pixels: Pixels::Rgba8(vec![255, 0, 0, 255, 255, 0, 0, 0]), transform: Affine::IDENTITY });
|
||||
let out = apply(&n, &mut |_| {}).into_owned();
|
||||
let Kind::Image(img) = &out.kind else { panic!() };
|
||||
assert_eq!(img.pixels, Pixels::Rgba8(vec![0, 0, 255, 255, 255, 0, 0, 0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gradient_overlay_over_one_colour_becomes_that_gradient() {
|
||||
let g = Gradient {
|
||||
kind: GradientKind::Linear,
|
||||
stops: vec![
|
||||
Stop { offset: 0.0, color: Color::Rgb { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }, bias: 0.5 },
|
||||
Stop { offset: 1.0, color: Color::Rgb { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }, bias: 0.5 },
|
||||
],
|
||||
transform: Affine([10.0, 0.0, 0.0, 10.0, 0.0, 0.0]),
|
||||
};
|
||||
let mut n = solid(Color::Rgb { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }, 1.0, Blend::Normal);
|
||||
n.effects = vec![Effect::GradientOverlay { gradient: g.clone(), opacity: 1.0, blend: Blend::Normal }];
|
||||
let out = apply(&n, &mut |_| {}).into_owned();
|
||||
let Kind::Shape { fills, .. } = &out.kind else { panic!() };
|
||||
assert_eq!(fills.first(), Some(&Paint::Gradient(g)));
|
||||
}
|
||||
}
|
||||
@@ -283,7 +283,7 @@ pub(crate) fn node_paint(r: &mut Reader, id: ObjId, local: Affine) -> (Vec<Paint
|
||||
(fills, stroke.into_iter().collect())
|
||||
}
|
||||
|
||||
fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, behind: bool) -> Option<Stroke> {
|
||||
pub(crate) fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, behind: bool) -> Option<Stroke> {
|
||||
let s = r.s;
|
||||
let paint = descriptor(r, pen, local)?;
|
||||
if paint == Paint::None {
|
||||
@@ -295,6 +295,13 @@ fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, behind: bool)
|
||||
let scales = s.bool(ldsc, b"LDSc").unwrap_or(true);
|
||||
let width = if scales { weight * local.scale() } else { weight };
|
||||
let data = s.bytes(lsty, b"Data").unwrap_or_default();
|
||||
// Byte 10 is the stroke's style: 0 none, 1 solid, 2 dashed, 3 a brush. A shape without a
|
||||
// stroke still stores one (black, 1 px wide), with style 0. Identified on two documents their
|
||||
// owner made, where every such stroke is invisible in Affinity's own PDF export, and checked
|
||||
// against the public corpus's thumbnails.
|
||||
if data.get(10) == Some(&0) {
|
||||
return None;
|
||||
}
|
||||
let miter_limit = data.get(0..8).and_then(|b| b.try_into().ok()).map(f64::from_le_bytes).filter(|m| m.is_finite() && *m >= 1.0).unwrap_or(4.0);
|
||||
// Cap and join bytes are only partly understood: (0, 0) is butt/miter in every clean sample;
|
||||
// (2, 2) ends plain lines square and brush lines round; joins import round, which never
|
||||
|
||||
+244
-57
@@ -39,6 +39,9 @@ pub(crate) fn node_image(r: &mut Reader, id: ObjId, world: Affine) -> Result<Opt
|
||||
&& (s.is(id, b"ImgN") || !has_tiles)
|
||||
{
|
||||
match original(r, name) {
|
||||
// HEIF/HEIC (phone photos) and AVIF originals can't be decoded here: the same pixels
|
||||
// stored as tiles are used instead (see `decode` when the tiles point back at the original).
|
||||
Ok(bytes) if is_heif(&bytes) && has_tiles => {}
|
||||
Ok(bytes) => return Ok(Some(Image { width, height, pixels: Pixels::Encoded(bytes), transform: world })),
|
||||
Err(Error::Limit(e)) => return Err(Error::Limit(e)),
|
||||
Err(_) => r.warn("an embedded image file that could not be read (its cached pixels were used)"),
|
||||
@@ -66,8 +69,17 @@ pub(crate) fn node_image(r: &mut Reader, id: ObjId, world: Affine) -> Result<Opt
|
||||
decode(r, bitmap, crop, world)
|
||||
}
|
||||
|
||||
/// A mask layer (`MRst`): one channel of coverage as grey pixels (white shows, black hides).
|
||||
pub(crate) fn mask(r: &mut Reader, id: ObjId, world: Affine) -> Result<Option<Image>, Error> {
|
||||
/// An ISO base media file holding a HEIF image (HEIC, AVIF): `ftyp` with an image brand.
|
||||
fn is_heif(bytes: &[u8]) -> bool {
|
||||
bytes.get(4..8) == Some(b"ftyp".as_slice())
|
||||
&& bytes.get(8..12).is_some_and(|brand| {
|
||||
[b"heic", b"heix", b"hevc", b"hevx", b"heim", b"heis", b"mif1", b"msf1", b"avif", b"avis"].iter().any(|b| brand == b.as_slice())
|
||||
})
|
||||
}
|
||||
|
||||
/// A mask layer (`MRst`): one channel of coverage as grey pixels (white shows, black hides), read
|
||||
/// over `over` (document pixels) when given; outside its pixels a mask hides everything.
|
||||
pub(crate) fn mask(r: &mut Reader, id: ObjId, world: Affine, over: Option<crate::model::Rect>) -> Result<Option<Image>, Error> {
|
||||
let s = r.s;
|
||||
let Some(bitmap) = s.obj(id, b"Bitm") else { return Ok(None) };
|
||||
let width = s.int(bitmap, b"BmpW").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
|
||||
@@ -76,7 +88,41 @@ pub(crate) fn mask(r: &mut Reader, id: ObjId, world: Affine) -> Result<Option<Im
|
||||
r.warn("pixel masks in an unknown format (imported without them)");
|
||||
return Ok(None);
|
||||
}
|
||||
decode(r, bitmap, (0, 0, width, height), world)
|
||||
let crop = match over.map(|o| crop_to(o, world, width, height)) {
|
||||
Some(Some(c)) => c,
|
||||
// What it masks lies outside it: all hidden, which one black pixel says as well.
|
||||
Some(None) => return Ok(Some(Image { width: 1, height: 1, pixels: Pixels::Rgba8(vec![0, 0, 0, 255]), transform: world })),
|
||||
None => (0, 0, width, height),
|
||||
};
|
||||
decode(r, bitmap, crop, world)
|
||||
}
|
||||
|
||||
/// The bitmap pixels (x0, y0, x1, y1) of a `width` × `height` bitmap placed by `world` that cover
|
||||
/// document rectangle `over`, one pixel wider all round; `None` if they don't overlap.
|
||||
fn crop_to(over: crate::model::Rect, world: Affine, width: u32, height: u32) -> Option<(u32, u32, u32, u32)> {
|
||||
let inv = invert(world)?;
|
||||
let pts = [(over.x0, over.y0), (over.x1, over.y0), (over.x1, over.y1), (over.x0, over.y1)].map(|(x, y)| inv.apply(crate::model::Point { x, y }));
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
|
||||
for p in pts {
|
||||
if !(p.x.is_finite() && p.y.is_finite()) {
|
||||
return Some((0, 0, width, height));
|
||||
}
|
||||
(x0, y0, x1, y1) = (x0.min(p.x), y0.min(p.y), x1.max(p.x), y1.max(p.y));
|
||||
}
|
||||
let clamp = |v: f64, max: u32| v.clamp(0.0, f64::from(max)) as u32;
|
||||
let (cx0, cy0, cx1, cy1) =
|
||||
(clamp(x0.floor() - 1.0, width), clamp(y0.floor() - 1.0, height), clamp(x1.ceil() + 1.0, width), clamp(y1.ceil() + 1.0, height));
|
||||
(cx1 > cx0 && cy1 > cy0).then_some((cx0, cy0, cx1, cy1))
|
||||
}
|
||||
|
||||
fn invert(m: Affine) -> Option<Affine> {
|
||||
let [a, b, c, d, e, f] = m.0;
|
||||
let det = a * d - b * c;
|
||||
if !(det.is_finite() && det.abs() > 1e-12) {
|
||||
return None;
|
||||
}
|
||||
let (ia, ib, ic, id) = (d / det, -b / det, -c / det, a / det);
|
||||
Some(Affine([ia, ib, ic, id, -(ia * e + ic * f), -(ib * e + id * f)]))
|
||||
}
|
||||
|
||||
/// An embedded document or symbol instance (`EmbN`), as the picture of it Affinity caches.
|
||||
@@ -99,18 +145,29 @@ pub(crate) fn embedded(r: &mut Reader, id: ObjId, world: Affine) -> Result<Optio
|
||||
|
||||
/// Decode the tiles of a bitmap inside `crop` (pixels), placed by `world` (bitmap pixels to document).
|
||||
fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affine) -> Result<Option<Image>, Error> {
|
||||
decode_within(r, bitmap, crop, world, MAX_PIXELS)
|
||||
}
|
||||
|
||||
/// [`decode`] with at most `max_pixels` decoded pixels.
|
||||
fn decode_within(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affine, max_pixels: u64) -> Result<Option<Image>, Error> {
|
||||
let s = r.s;
|
||||
let (x0, y0, x1, y1) = crop;
|
||||
if x1 <= x0 || y1 <= y0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let (w, h) = (x1 - x0, y1 - y0);
|
||||
let pixels = u64::from(w) * u64::from(h);
|
||||
if pixels > MAX_PIXELS {
|
||||
r.warn("pixel layers larger than 64 megapixels");
|
||||
return Ok(None);
|
||||
// Larger layers (mostly masks of whole spreads) are read at a half, a quarter… of their
|
||||
// resolution: every `step`th pixel of every `step`th row.
|
||||
let mut step = 1u32;
|
||||
while u64::from(w.div_ceil(step)) * u64::from(h.div_ceil(step)) > max_pixels {
|
||||
step *= 2;
|
||||
if step > 1 << 12 {
|
||||
r.warn("pixel layers too large to read (left out)");
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
// Before anything is allocated: a layer's tiles can be flat colour, with nothing to extract.
|
||||
let pixels = u64::from(w.div_ceil(step)) * u64::from(h.div_ceil(step));
|
||||
let Some(left) = r.pixels_left.checked_sub(pixels) else {
|
||||
r.warn("pixel layers beyond the pixels one document may hold");
|
||||
return Ok(None);
|
||||
@@ -122,10 +179,7 @@ fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affi
|
||||
Some(1) => (4, 2),
|
||||
Some(6) => (1, 1),
|
||||
Some(7) => (1, 2),
|
||||
Some(4) => {
|
||||
r.warn("CMYK pixel layers (converted to RGB without the document's colour profile)");
|
||||
(5, 1)
|
||||
}
|
||||
Some(4) => (5, 1),
|
||||
_ => {
|
||||
r.warn("pixel layers in grey, Lab or 32-bit formats");
|
||||
return Ok(None);
|
||||
@@ -152,11 +206,48 @@ fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affi
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Tiles kept only in an original that can't be decoded here are left empty (transparent). Where
|
||||
// a tile's colour is in the original but its alpha is stored, as in CMYK layers, it would come
|
||||
// out opaque white instead: such a layer (a HEIC photo in a CMYK document) is left out, and said.
|
||||
if needs_source && source.is_none() && channels > 1 {
|
||||
let states = |c: usize| match s.field(bitmap, &tag(b"Sta", c)) {
|
||||
Some(Value::Array(a)) => a.clone(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let alpha = states(channels);
|
||||
let blank = (1..channels).any(|c| states(c).iter().enumerate().any(|(i, v)| *v == Value::UInt(5) && alpha.get(i) != Some(&Value::UInt(5))));
|
||||
if blank {
|
||||
r.warn("placed images whose pixels are only in an original the reader can't decode, such as HEIC photos (left out)");
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
if step > 1 {
|
||||
// Its stored tiles must fit what is left of the import's extraction budget.
|
||||
let stored: usize = (1..=channels).map(|c| tiles_in(s, bitmap, c, bps, (x0, y0, w, h)).len()).sum();
|
||||
if stored.saturating_mul(TILE_BYTES) > r.archive.remaining().max_total {
|
||||
r.warn("pixel layers too large to read (left out)");
|
||||
return Ok(None);
|
||||
}
|
||||
r.warn("pixel layers larger than 64 megapixels (read at a lower resolution)");
|
||||
}
|
||||
let mut cache = CachedPixels { tiles: HashMap::new(), source };
|
||||
for c in 1..=channels {
|
||||
planes.push(plane(r, bitmap, c, bps, (x0, y0, w, h), &mut cache)?);
|
||||
planes.push(plane(r, bitmap, c, bps, (x0, y0, w, h), step, &mut cache)?);
|
||||
}
|
||||
let (w, h) = (w.div_ceil(step), h.div_ceil(step));
|
||||
let n = (w as usize) * (h as usize);
|
||||
let k = f64::from(step);
|
||||
let transform = Affine([k, 0.0, 0.0, k, f64::from(x0), f64::from(y0)]).then(world);
|
||||
if channels == 5 {
|
||||
// Ink amounts and alpha as stored (8 bits), interleaved.
|
||||
let mut cmyka = vec![0u8; n * 5];
|
||||
for (i, px) in cmyka.as_chunks_mut::<5>().0.iter_mut().enumerate() {
|
||||
for (c, v) in px.iter_mut().enumerate() {
|
||||
*v = planes.get(c).and_then(|p| p.get(i)).copied().unwrap_or(0);
|
||||
}
|
||||
}
|
||||
return Ok(Some(Image { width: w, height: h, pixels: Pixels::Cmyka8(cmyka), transform }));
|
||||
}
|
||||
let mut rgba = vec![0u8; n * 4];
|
||||
let sample = |p: &Vec<u8>, i: usize| -> f64 {
|
||||
if bps == 2 {
|
||||
@@ -167,17 +258,9 @@ fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affi
|
||||
};
|
||||
for (i, px) in rgba.as_chunks_mut::<4>().0.iter_mut().enumerate() {
|
||||
let v: Vec<f64> = planes.iter().map(|p| sample(p, i)).collect();
|
||||
let (r2, g, b, a) = if channels == 1 {
|
||||
(v[0], v[0], v[0], 1.0)
|
||||
} else if channels == 5 {
|
||||
let k = 1.0 - v[3];
|
||||
((1.0 - v[0]) * k, (1.0 - v[1]) * k, (1.0 - v[2]) * k, v[4])
|
||||
} else {
|
||||
(v[0], v[1], v[2], v[3])
|
||||
};
|
||||
let (r2, g, b, a) = if channels == 1 { (v[0], v[0], v[0], 1.0) } else { (v[0], v[1], v[2], v[3]) };
|
||||
px.copy_from_slice(&[r2, g, b, a].map(|c| (c.clamp(0.0, 1.0) * 255.0).round() as u8));
|
||||
}
|
||||
let transform = Affine([1.0, 0.0, 0.0, 1.0, f64::from(x0), f64::from(y0)]).then(world);
|
||||
Ok(Some(Image { width: w, height: h, pixels: Pixels::Rgba8(rgba), transform }))
|
||||
}
|
||||
|
||||
@@ -229,28 +312,57 @@ fn tag(prefix: &[u8; 3], c: usize) -> [u8; 4] {
|
||||
[prefix[0], prefix[1], prefix[2], b'0' + c as u8]
|
||||
}
|
||||
|
||||
/// One channel of level 0, cropped to (x0, y0, w, h) pixels; `bps` bytes per sample.
|
||||
/// Channel `c`'s tile grid: (tiles across, tiles down, each tile's state).
|
||||
fn grid(s: &stream::Stream, bitmap: ObjId, c: usize, bps: usize) -> (usize, usize, Vec<u8>) {
|
||||
let width = s.int(bitmap, b"BmpW").and_then(|v| usize::try_from(v).ok()).unwrap_or(0);
|
||||
let height = s.int(bitmap, b"BmpH").and_then(|v| usize::try_from(v).ok()).unwrap_or(0);
|
||||
let tw = s.int(bitmap, &tag(b"TWi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| width.saturating_mul(bps).div_ceil(TILE));
|
||||
let th = s.int(bitmap, &tag(b"THi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| height.div_ceil(TILE));
|
||||
let states: Vec<u8> = match s.field(bitmap, &tag(b"Sta", c)) {
|
||||
Some(Value::Array(a)) => a.iter().map(|v| if let Value::UInt(u) = v { u8::try_from(*u).unwrap_or(255) } else { 255 }).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
(tw, th, states)
|
||||
}
|
||||
|
||||
/// The stored tiles (state 4) of channel `c` that overlap the crop, by their index in `Idx`.
|
||||
fn tiles_in(s: &stream::Stream, bitmap: ObjId, c: usize, bps: usize, (x0, y0, w, h): (u32, u32, u32, u32)) -> Vec<usize> {
|
||||
let (tw, th, states) = grid(s, bitmap, c, bps);
|
||||
let (bx0, bx1) = (x0 as usize * bps, (x0 + w) as usize * bps);
|
||||
let (py0, py1) = (y0 as usize, (y0 + h) as usize);
|
||||
let mut out = Vec::new();
|
||||
let mut next = 0usize;
|
||||
for (t, state) in states.iter().enumerate().take(tw.saturating_mul(th)) {
|
||||
if *state != 4 {
|
||||
continue;
|
||||
}
|
||||
let (ox, oy) = ((t % tw.max(1)) * TILE, (t / tw.max(1)) * TILE);
|
||||
if !(ox >= bx1 || ox + TILE <= bx0 || oy >= py1 || oy + TILE <= py0) {
|
||||
out.push(next);
|
||||
}
|
||||
next += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// One channel of level 0, cropped to (x0, y0, w, h) pixels and taking every `step`th pixel of
|
||||
/// every `step`th row; `bps` bytes per sample.
|
||||
fn plane(
|
||||
r: &mut Reader,
|
||||
bitmap: ObjId,
|
||||
c: usize,
|
||||
bps: usize,
|
||||
(x0, y0, w, h): (u32, u32, u32, u32),
|
||||
step: u32,
|
||||
cache: &mut CachedPixels,
|
||||
) -> Result<Vec<u8>, Error> {
|
||||
let CachedPixels { tiles: cache, source } = cache;
|
||||
let s = r.s;
|
||||
let width = s.int(bitmap, b"BmpW").and_then(|v| usize::try_from(v).ok()).unwrap_or(0);
|
||||
let height = s.int(bitmap, b"BmpH").and_then(|v| usize::try_from(v).ok()).unwrap_or(0);
|
||||
let tw = s.int(bitmap, &tag(b"TWi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| (width * bps).div_ceil(TILE));
|
||||
let th = s.int(bitmap, &tag(b"THi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| height.div_ceil(TILE));
|
||||
let states: Vec<u8> = match s.field(bitmap, &tag(b"Sta", c)) {
|
||||
Some(Value::Array(a)) => a.iter().map(|v| if let Value::UInt(u) = v { u8::try_from(*u).unwrap_or(255) } else { 255 }).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let (tw, th, states) = grid(s, bitmap, c, bps);
|
||||
let tiles = s.objs(bitmap, &tag(b"Idx", c));
|
||||
let row = (w as usize) * bps;
|
||||
let mut out = vec![0u8; row * h as usize];
|
||||
let at = Crop { bps, x0: x0 as usize, y0: y0 as usize, w: w as usize, h: h as usize, step: step.max(1) as usize };
|
||||
let row = at.w.div_ceil(at.step) * bps;
|
||||
let mut out = vec![0u8; row * at.h.div_ceil(at.step)];
|
||||
let (bx0, bx1) = (x0 as usize * bps, (x0 + w) as usize * bps);
|
||||
let (py0, py1) = (y0 as usize, (y0 + h) as usize);
|
||||
let mut next = 0usize;
|
||||
@@ -267,7 +379,7 @@ fn plane(
|
||||
continue;
|
||||
}
|
||||
let name = s.entry(blck, b"Data").ok_or(Error::Malformed("pixel tile without data"))?.to_string();
|
||||
if !cache.contains_key(&name) {
|
||||
let read = |r: &mut Reader| -> Result<Vec<u8>, Error> {
|
||||
let mut data = r.archive.read(&name)?;
|
||||
// Older files wrap a tile in a one-field stream: header, then a 64 KiB blob.
|
||||
if data.len() == TILE_BYTES + 22 && data.starts_with(b"\x00\xffKS") {
|
||||
@@ -276,10 +388,20 @@ fn plane(
|
||||
if data.len() != TILE_BYTES {
|
||||
return Err(Error::Malformed("pixel tile size"));
|
||||
}
|
||||
Ok(data)
|
||||
};
|
||||
// A layer read at a lower resolution is too large to keep its tiles around.
|
||||
if at.step > 1 {
|
||||
let tile = read(r)?;
|
||||
copy(&mut out, &at, (ox, oy), |x, y| tile.get(y * TILE + x).copied().unwrap_or(0));
|
||||
continue;
|
||||
}
|
||||
if !cache.contains_key(&name) {
|
||||
let data = read(r)?;
|
||||
cache.insert(name.clone(), data);
|
||||
}
|
||||
let tile = cache.get(&name).ok_or(Error::Malformed("pixel tile"))?;
|
||||
copy(&mut out, row, (bx0, py0, bx1, py1), (ox, oy), |x, y| tile.get(y * TILE + x).copied().unwrap_or(0));
|
||||
copy(&mut out, &at, (ox, oy), |x, y| tile.get(y * TILE + x).copied().unwrap_or(0));
|
||||
continue;
|
||||
}
|
||||
3 => {
|
||||
@@ -289,9 +411,7 @@ fn plane(
|
||||
5 => {
|
||||
if let Some((source_width, pixels)) = source.as_ref() {
|
||||
let (ox, oy) = (tx * TILE, ty * TILE);
|
||||
copy(&mut out, row, (bx0, py0, bx1, py1), (ox, oy), |x, y| {
|
||||
pixels.get(((oy + y) * source_width + ox + x) * 4 + c - 1).copied().unwrap_or(0)
|
||||
});
|
||||
copy(&mut out, &at, (ox, oy), |x, y| pixels.get(((oy + y) * source_width + ox + x) * 4 + c - 1).copied().unwrap_or(0));
|
||||
continue;
|
||||
}
|
||||
r.warn("pixel layers drawn from an embedded image (left empty)");
|
||||
@@ -300,17 +420,38 @@ fn plane(
|
||||
_ => return Err(Error::Malformed("unknown pixel tile state")),
|
||||
};
|
||||
if let Some(v) = fill {
|
||||
copy(&mut out, row, (bx0, py0, bx1, py1), (tx * TILE, ty * TILE), |_, _| v);
|
||||
copy(&mut out, &at, (tx * TILE, ty * TILE), |_, _| v);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Copy the overlap of a tile at byte column `ox`, row `oy` into the cropped plane.
|
||||
fn copy(out: &mut [u8], row: usize, (bx0, py0, bx1, py1): (usize, usize, usize, usize), (ox, oy): (usize, usize), at: impl Fn(usize, usize) -> u8) {
|
||||
for y in oy.max(py0)..(oy + TILE).min(py1) {
|
||||
/// Where a plane's pixels come from: the crop (pixels) and the sampling step.
|
||||
struct Crop {
|
||||
bps: usize,
|
||||
x0: usize,
|
||||
y0: usize,
|
||||
w: usize,
|
||||
h: usize,
|
||||
step: usize,
|
||||
}
|
||||
|
||||
/// Copy the overlap of a tile at byte column `ox`, row `oy` into the cropped plane, keeping every
|
||||
/// `step`th pixel of every `step`th row.
|
||||
fn copy(out: &mut [u8], c: &Crop, (ox, oy): (usize, usize), at: impl Fn(usize, usize) -> u8) {
|
||||
let row = c.w.div_ceil(c.step) * c.bps;
|
||||
let (bx0, bx1) = (c.x0 * c.bps, (c.x0 + c.w) * c.bps);
|
||||
for y in oy.max(c.y0)..(oy + TILE).min(c.y0 + c.h) {
|
||||
if !(y - c.y0).is_multiple_of(c.step) {
|
||||
continue;
|
||||
}
|
||||
let out_row = (y - c.y0) / c.step * row;
|
||||
for x in ox.max(bx0)..(ox + TILE).min(bx1) {
|
||||
if let Some(b) = out.get_mut((y - py0) * row + (x - bx0)) {
|
||||
let px = x / c.bps - c.x0;
|
||||
if !px.is_multiple_of(c.step) {
|
||||
continue;
|
||||
}
|
||||
if let Some(b) = out.get_mut(out_row + px / c.step * c.bps + x % c.bps) {
|
||||
*b = at(x - ox, y - oy);
|
||||
}
|
||||
}
|
||||
@@ -323,6 +464,15 @@ mod tests {
|
||||
use crate::model::Kind;
|
||||
use crate::synth::{self, F, Method, tag};
|
||||
|
||||
#[test]
|
||||
fn heif_originals_are_recognised_by_their_brand() {
|
||||
assert!(is_heif(b"\0\0\0\x18ftypheic\0\0\0\0"));
|
||||
assert!(is_heif(b"\0\0\0\x1cftypavif"));
|
||||
assert!(!is_heif(b"\0\0\0\x18ftypisom"), "a video is not a photo");
|
||||
assert!(!is_heif(&png()));
|
||||
assert!(!is_heif(b"ftyp"));
|
||||
}
|
||||
|
||||
fn png() -> Vec<u8> {
|
||||
let pixels = image::RgbaImage::from_fn(300, 2, |x, y| image::Rgba([(x % 251) as u8, (y * 31) as u8, 77, 255]));
|
||||
let mut bytes = std::io::Cursor::new(Vec::new());
|
||||
@@ -333,18 +483,29 @@ mod tests {
|
||||
/// An original synthetic mixed source/cache bitmap. It exercises reader recovery,
|
||||
/// not a claim that Affinity itself accepts the synthetic container.
|
||||
fn fixture(source: &[u8], origin: i32) -> Vec<u8> {
|
||||
fixture_in(source, origin, false)
|
||||
}
|
||||
|
||||
/// `cmyk`: a CMYK bitmap whose colour tiles are all in the source and whose alpha is stored.
|
||||
fn fixture_in(source: &[u8], origin: i32, cmyk: bool) -> Vec<u8> {
|
||||
let channels = if cmyk { 5 } else { 4 };
|
||||
let mut bitmap = vec![
|
||||
(tag(b"Frmt"), F::Enum(0, 0)),
|
||||
(tag(b"Frmt"), F::Enum(if cmyk { 4 } else { 0 }, 0)),
|
||||
(tag(b"BmpW"), F::I32(300)),
|
||||
(tag(b"BmpH"), F::I32(2)),
|
||||
(tag(b"LInf"), F::I32(origin)),
|
||||
(tag(b"TInf"), F::I32(0)),
|
||||
(tag(b"Bckg"), F::Entry("c/1".into())),
|
||||
];
|
||||
for c in 1..=4 {
|
||||
for c in 1..=channels {
|
||||
let states = match (cmyk, c) {
|
||||
(false, _) => vec![5, 4],
|
||||
(true, 5) => vec![4, 4],
|
||||
(true, _) => vec![5, 5],
|
||||
};
|
||||
bitmap.push((tag(&super::tag(b"TWi", c)), F::I32(2)));
|
||||
bitmap.push((tag(&super::tag(b"THi", c)), F::I32(1)));
|
||||
bitmap.push((tag(&super::tag(b"Sta", c)), F::U8s(vec![5, 4])));
|
||||
bitmap.push((tag(&super::tag(b"Sta", c)), F::U8s(states)));
|
||||
bitmap.push((
|
||||
tag(&super::tag(b"Idx", c)),
|
||||
F::Shared(vec![F::Def(c as u32, vec![tag(b"Blck")], vec![(tag(b"Data"), F::Entry(format!("d/{c}")))])]),
|
||||
@@ -369,18 +530,44 @@ mod tests {
|
||||
blob.extend((source.len() as u32).to_le_bytes());
|
||||
blob.extend(source);
|
||||
let block = synth::stream(&[(tag(b"Data"), F::Raw(blob))]);
|
||||
let tiles: Vec<_> = [200u8, 10, 30, 255].into_iter().map(|v| vec![v; TILE_BYTES]).collect();
|
||||
synth::container(
|
||||
&[
|
||||
("doc.dat", &doc, Method::Zlib),
|
||||
("c/1", &block, Method::Zlib),
|
||||
("d/1", &tiles[0], Method::Zlib),
|
||||
("d/2", &tiles[1], Method::Zlib),
|
||||
("d/3", &tiles[2], Method::Zlib),
|
||||
("d/4", &tiles[3], Method::Zlib),
|
||||
],
|
||||
None,
|
||||
)
|
||||
let tiles: Vec<_> = [200u8, 10, 30, 255, 255].into_iter().map(|v| vec![v; TILE_BYTES]).collect();
|
||||
let names: Vec<String> = (1..=channels).map(|c| format!("d/{c}")).collect();
|
||||
let mut entries = vec![("doc.dat", doc.as_slice(), Method::Zlib), ("c/1", block.as_slice(), Method::Zlib)];
|
||||
for (name, tile) in names.iter().zip(&tiles) {
|
||||
entries.push((name.as_str(), tile.as_slice(), Method::Zlib));
|
||||
}
|
||||
synth::container(&entries, None)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cmyk_layer_whose_colours_are_only_in_an_unreadable_original_is_left_out_not_white() {
|
||||
let doc = crate::read(&fixture_in(b"\0\0\0\x18ftypheic not decodable", 0, true), crate::Limits::default()).unwrap();
|
||||
assert!(doc.warnings.iter().any(|w| w.starts_with("placed images whose pixels are only in an original")), "{:?}", doc.warnings);
|
||||
assert!(matches!(doc.spreads[0].nodes[0].kind, Kind::Unsupported), "{:?}", doc.spreads[0].nodes[0].kind);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_over_the_pixel_budget_are_read_at_a_lower_resolution() {
|
||||
let bytes = fixture(&png(), 0);
|
||||
let mut archive = crate::Archive::open(&bytes, crate::Limits::default()).unwrap();
|
||||
let doc = archive.read("doc.dat").unwrap();
|
||||
let s = stream::parse(&doc).unwrap();
|
||||
let bitmap = s.objects.iter().position(|o| o.class == Tag::of(b"DyBm")).unwrap();
|
||||
let mut r = Reader::new(&s, &mut archive, Affine::IDENTITY, 0);
|
||||
// 20 × 2 pixels over a budget of 10: every other pixel of every other row.
|
||||
let image = decode_within(&mut r, bitmap, (250, 0, 270, 2), Affine::IDENTITY, 10).unwrap().unwrap();
|
||||
assert_eq!((image.width, image.height), (10, 1));
|
||||
assert_eq!(image.transform, Affine([2.0, 0.0, 0.0, 2.0, 250.0, 0.0]), "each pixel covers two");
|
||||
let Pixels::Rgba8(pixels) = &image.pixels else { panic!() };
|
||||
for x in 0..10 {
|
||||
let sx = 2 * x;
|
||||
let expected = if sx + 250 < 256 { [((sx + 250) % 251) as u8, 0, 77, 255] } else { [200, 10, 30, 255] };
|
||||
assert_eq!(&pixels[x * 4..x * 4 + 4], &expected, "pixel {x}");
|
||||
}
|
||||
assert!(r.warnings.contains_key("pixel layers larger than 64 megapixels (read at a lower resolution)"));
|
||||
// Within budget, nothing changes.
|
||||
let image = decode_within(&mut r, bitmap, (250, 0, 270, 2), Affine::IDENTITY, 40).unwrap().unwrap();
|
||||
assert_eq!((image.width, image.height), (20, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+282
-98
@@ -2,7 +2,7 @@
|
||||
//! leading, the paragraph alignment and the first baseline. Layout (line breaks in frames, kerning,
|
||||
//! OpenType features) is left to the importing application.
|
||||
|
||||
use crate::model::{Affine, Align, Point, Reader, Text, TextRun, rect};
|
||||
use crate::model::{Affine, Align, Paragraph, Point, Reader, Table, TableCell, Text, TextRun, VAlign, rect};
|
||||
use crate::paint::{self, Color, Paint};
|
||||
use crate::stream::{ObjId, Value};
|
||||
|
||||
@@ -11,92 +11,16 @@ const MAX_CHARS: usize = 1 << 20;
|
||||
/// Shared character attributes must not amplify into unbounded font/feature/gradient data.
|
||||
const MAX_ATTRIBUTE_BYTES: usize = 16 << 20;
|
||||
|
||||
/// Ends a table cell in a table's story (Affinity's break glyph of kind 4).
|
||||
pub const CELL_BREAK: char = '\u{1F}';
|
||||
|
||||
pub(crate) fn read(r: &mut Reader, id: ObjId, world: Affine) -> Option<Text> {
|
||||
let s = r.s;
|
||||
let story = s.obj(id, b"StSt")?;
|
||||
let frame = s.obj(id, b"TxtH")?;
|
||||
let artistic = s.is(frame, b"ArFr");
|
||||
let bounds = s.floats::<4>(frame, b"FrmB").map(rect)?;
|
||||
let mut runs = Vec::new();
|
||||
let mut align = None;
|
||||
let mut total = 0usize;
|
||||
let mut placeholders = false;
|
||||
let mut attribute_budget = Some(MAX_ATTRIBUTE_BYTES);
|
||||
for block in s.objs(story, b"Blok") {
|
||||
let Some(glyphs) = s.obj(block, b"Glyp") else {
|
||||
r.warn("a text block without readable characters (left out)");
|
||||
continue;
|
||||
};
|
||||
// Bound collection itself, including placeholders, before allocating the character buffer.
|
||||
let budget = MAX_CHARS - total;
|
||||
|
||||
let mut chars: Vec<char> = match s.str(glyphs, b"Utf8") {
|
||||
Some(t) => t.chars().take(budget + 1).collect(),
|
||||
None => {
|
||||
// Inline objects (page numbers, anchors) each take one index before the segment text.
|
||||
let segments = s.objs(glyphs, b"Mixd");
|
||||
if segments.is_empty() {
|
||||
r.warn("a text block without readable characters (left out)");
|
||||
continue;
|
||||
}
|
||||
let mut v = Vec::new();
|
||||
for seg in segments {
|
||||
let inline = s.objs(seg, b"Glys").len();
|
||||
v.extend(std::iter::repeat_n('\u{FFFC}', inline.min(budget + 1 - v.len())));
|
||||
placeholders |= inline > 0;
|
||||
v.extend(s.str(seg, b"Utf8").unwrap_or_default().chars().take(budget + 1 - v.len()));
|
||||
if v.len() > budget {
|
||||
break;
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
};
|
||||
let truncated = chars.len() > budget;
|
||||
if truncated {
|
||||
chars.truncate(budget);
|
||||
r.warn("text longer than a million characters (truncated)");
|
||||
}
|
||||
total += chars.len();
|
||||
let mut start = 0usize;
|
||||
for run in s.obj(block, b"GAtt").map(|g| s.objs(g, b"Runs")).unwrap_or_default() {
|
||||
let Some(index) = s.int(run, b"Indx").and_then(|v| usize::try_from(v).ok()) else {
|
||||
r.warn("invalid text attribute range (bounded; unassigned characters use defaults)");
|
||||
continue;
|
||||
};
|
||||
let end = index.min(chars.len());
|
||||
if index > chars.len() || end < start {
|
||||
r.warn("invalid text attribute range (bounded; unassigned characters use defaults)");
|
||||
}
|
||||
if end <= start {
|
||||
continue;
|
||||
}
|
||||
let text = characters(&chars, start, end);
|
||||
start = end;
|
||||
push_run(r, &mut runs, s.obj(run, b"Item"), text, world, &mut attribute_budget);
|
||||
}
|
||||
if start < chars.len() {
|
||||
let text = characters(&chars, start, chars.len());
|
||||
push_run(r, &mut runs, None, text, world, &mut attribute_budget);
|
||||
}
|
||||
for paragraph in s.obj(block, b"PAtt").map(|p| s.objs(p, b"Runs")).unwrap_or_default() {
|
||||
if let Some(attrs) = s.obj(paragraph, b"Item") {
|
||||
let paragraph = paragraph_align(r, attrs);
|
||||
match align {
|
||||
None => align = Some(paragraph),
|
||||
Some(first) if first != paragraph => r.warn("mixed paragraph alignments (the first alignment is used)"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if truncated {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if placeholders {
|
||||
r.warn("text fields such as page numbers (left out of the text)");
|
||||
}
|
||||
let align = align.unwrap_or(Align::Left);
|
||||
let (runs, align) = story_runs(r, story, world);
|
||||
let first_baseline = bounds.y0 + s.f64(frame, b"ArtV").unwrap_or(0.0);
|
||||
let anchor_x = match align {
|
||||
Align::Center => (bounds.x0 + bounds.x1) / 2.0,
|
||||
@@ -111,19 +35,231 @@ pub(crate) fn read(r: &mut Reader, id: ObjId, world: Affine) -> Option<Text> {
|
||||
r.warn("text frames with a curved outline (imported as rectangular frames)");
|
||||
}
|
||||
}
|
||||
Some(Text { runs, align, anchor: Point { x: anchor_x, y: first_baseline }, frame: if artistic { None } else { Some(bounds) }, transform: world })
|
||||
let text_transform = if artistic { world } else { text_scale(r, id).then(r.type_base) };
|
||||
Some(Text {
|
||||
runs,
|
||||
align,
|
||||
anchor: Point { x: anchor_x, y: first_baseline },
|
||||
frame: if artistic { None } else { Some(bounds) },
|
||||
transform: world,
|
||||
text_transform,
|
||||
})
|
||||
}
|
||||
|
||||
/// Rows and columns a table may have.
|
||||
const MAX_TABLE_LINES: usize = 4096;
|
||||
|
||||
/// A table node (`TxtT`): the grid, the cells' text and the lines on the cell edges.
|
||||
pub(crate) fn table(r: &mut Reader, id: ObjId, world: Affine) -> Option<Table> {
|
||||
let s = r.s;
|
||||
let story = s.obj(id, b"StSt")?;
|
||||
let tabl = s.obj(id, b"TxtH").and_then(|h| s.obj(h, b"Tabl"))?;
|
||||
let bounds = |pos: Option<ObjId>| -> Option<Vec<f64>> {
|
||||
let Some(Value::Array(a)) = pos.and_then(|p| s.field(p, b"Posn")) else { return None };
|
||||
let v: Vec<f64> = a.iter().take(MAX_TABLE_LINES + 2).map(float).collect::<Option<_>>()?;
|
||||
(v.len() >= 2 && v.len() <= MAX_TABLE_LINES + 1 && v.windows(2).all(|w| w[1] >= w[0])).then_some(v)
|
||||
};
|
||||
let columns = bounds(s.obj(tabl, b"CPos"))?;
|
||||
let rows = bounds(s.obj(tabl, b"RPos"))?;
|
||||
let (ncols, nrows) = (columns.len() - 1, rows.len() - 1);
|
||||
let count = ncols.checked_mul(nrows)?;
|
||||
let cell_objs = s.obj(tabl, b"Cell").map(|c| s.objs(c, b"Cell")).unwrap_or_default();
|
||||
if cell_objs.len() != count {
|
||||
r.warn("tables whose cells don't match their grid (cell formatting left out)");
|
||||
}
|
||||
let blank = || TableCell { runs: Vec::new(), inset: [0.0; 4], valign: VAlign::Top, merged_left: false };
|
||||
let mut cells: Vec<TableCell> = (0..count).map(|_| blank()).collect();
|
||||
for (i, (cell, obj)) in cells.iter_mut().zip(&cell_objs).enumerate() {
|
||||
if let Some(inset) = s.floats::<4>(*obj, b"Inse").filter(|v| v.iter().all(|x| x.is_finite() && *x >= 0.0)) {
|
||||
cell.inset = inset;
|
||||
}
|
||||
cell.valign = match s.enumeration(*obj, b"AliY").map(|(i, _)| i) {
|
||||
Some(1) => VAlign::Center,
|
||||
Some(2) => VAlign::Bottom,
|
||||
_ => VAlign::Top,
|
||||
};
|
||||
// A cell merged into the one on its left; the first column has nothing to its left.
|
||||
cell.merged_left = i % ncols != 0 && s.bool(*obj, b"BrLf") == Some(true);
|
||||
}
|
||||
// The story holds the text of every cell that isn't merged away, row by row, each ended by a
|
||||
// cell break.
|
||||
let owner_list: Vec<usize> = (0..count).filter(|i| cells.get(*i).is_some_and(|c| !c.merged_left)).collect();
|
||||
let mut owners = owner_list.into_iter();
|
||||
let mut target = owners.next();
|
||||
let mut extra = false;
|
||||
let (runs, _) = story_runs(r, story, world);
|
||||
for run in runs {
|
||||
let mut parts = run.text.split(CELL_BREAK).peekable();
|
||||
while let Some(part) = parts.next() {
|
||||
if !part.is_empty() {
|
||||
match target.and_then(|i| cells.get_mut(i)) {
|
||||
Some(cell) => cell.runs.push(TextRun { text: part.to_string(), ..run.clone() }),
|
||||
None => extra = true,
|
||||
}
|
||||
}
|
||||
if parts.peek().is_some() {
|
||||
target = owners.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
if extra {
|
||||
r.warn("table text beyond its last cell (left out)");
|
||||
}
|
||||
let mut edges = |tag: &[u8; 4], expected: usize| -> Vec<Option<crate::paint::Stroke>> {
|
||||
let objs = s.obj(tabl, tag).map(|e| s.objs(e, b"Edge")).unwrap_or_default();
|
||||
if objs.len() != expected {
|
||||
r.warn("tables whose lines don't match their grid (lines left out)");
|
||||
return vec![None; expected];
|
||||
}
|
||||
objs.into_iter()
|
||||
.map(|e| match (s.obj(e, b"Fill"), s.obj(e, b"Line")) {
|
||||
(Some(pen), Some(line)) => paint::stroke(r, pen, line, world, false),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let vertical = edges(b"CEdg", (ncols + 1) * nrows);
|
||||
let horizontal = edges(b"REdg", ncols * (nrows + 1));
|
||||
if let Some(format) = s.obj(tabl, b"TFmt") {
|
||||
let filled =
|
||||
s.objs(format, b"Clls").into_iter().filter_map(|c| s.obj(c, b"Fill")).any(|f| s.obj(f, b"FDeF").is_some_and(|d| !s.is(d, b"FilN")));
|
||||
if filled {
|
||||
r.warn("table cell fills (left out)");
|
||||
}
|
||||
}
|
||||
let text_transform = text_scale(r, id).then(r.type_base);
|
||||
Some(Table { columns, rows, cells, vertical, horizontal, transform: world, text_transform })
|
||||
}
|
||||
|
||||
/// A story's character runs, each with its paragraph's format, and the first paragraph's
|
||||
/// alignment. Table cells end with [`CELL_BREAK`].
|
||||
pub(crate) fn story_runs(r: &mut Reader, story: ObjId, world: Affine) -> (Vec<TextRun>, Align) {
|
||||
let s = r.s;
|
||||
let mut runs = Vec::new();
|
||||
let mut first_align = None;
|
||||
let mut total = 0usize;
|
||||
let mut placeholders = false;
|
||||
let mut attribute_budget = Some(MAX_ATTRIBUTE_BYTES);
|
||||
for block in s.objs(story, b"Blok") {
|
||||
let Some(glyphs) = s.obj(block, b"Glyp") else {
|
||||
r.warn("a text block without readable characters (left out)");
|
||||
continue;
|
||||
};
|
||||
// Bound collection itself, including placeholders, before allocating the character buffer.
|
||||
let budget = MAX_CHARS - total;
|
||||
|
||||
let mut chars: Vec<char> = match s.str(glyphs, b"Utf8") {
|
||||
Some(t) => t.chars().take(budget + 1).collect(),
|
||||
None => {
|
||||
// Inline objects (page numbers, anchors, cell ends) each take one index before
|
||||
// the segment text.
|
||||
let segments = s.objs(glyphs, b"Mixd");
|
||||
if segments.is_empty() {
|
||||
r.warn("a text block without readable characters (left out)");
|
||||
continue;
|
||||
}
|
||||
let mut v = Vec::new();
|
||||
for seg in segments {
|
||||
for inline in s.objs(seg, b"Glys") {
|
||||
if v.len() > budget {
|
||||
break;
|
||||
}
|
||||
if s.is(inline, b"BrGl") && s.enumeration(inline, b"HdBk").is_some_and(|(kind, _)| kind == 4) {
|
||||
v.push(CELL_BREAK);
|
||||
} else {
|
||||
v.push('\u{FFFC}');
|
||||
placeholders = true;
|
||||
}
|
||||
}
|
||||
v.extend(s.str(seg, b"Utf8").unwrap_or_default().chars().take((budget + 1).saturating_sub(v.len())));
|
||||
if v.len() > budget {
|
||||
break;
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
};
|
||||
let truncated = chars.len() > budget;
|
||||
if truncated {
|
||||
chars.truncate(budget);
|
||||
r.warn("text longer than a million characters (truncated)");
|
||||
}
|
||||
total += chars.len();
|
||||
// Paragraph formats by end index; a run without an index covers the rest of the block.
|
||||
let mut paragraphs: Vec<(usize, Paragraph)> = Vec::new();
|
||||
let mut open: Option<Paragraph> = None;
|
||||
for run in s.obj(block, b"PAtt").map(|p| s.objs(p, b"Runs")).unwrap_or_default() {
|
||||
let Some(attrs) = s.obj(run, b"Item") else { continue };
|
||||
let a = paragraph(r, attrs);
|
||||
first_align.get_or_insert(a.align);
|
||||
match s.int(run, b"Indx").and_then(|v| usize::try_from(v).ok()) {
|
||||
Some(end) => paragraphs.push((end, a)),
|
||||
None => match open {
|
||||
None => open = Some(a),
|
||||
Some(first) if first != a => r.warn("mixed paragraph formats in one block (the first is used)"),
|
||||
_ => {}
|
||||
},
|
||||
}
|
||||
}
|
||||
let align_at = |i: usize| paragraphs.iter().find(|(end, _)| i < *end).map(|(_, a)| *a).or(open).unwrap_or_default();
|
||||
let mut cuts: Vec<usize> = paragraphs.iter().map(|(end, _)| *end).filter(|e| *e < chars.len()).collect();
|
||||
cuts.sort_unstable();
|
||||
cuts.dedup();
|
||||
let push = |r: &mut Reader, runs: &mut Vec<TextRun>, attrs: Option<ObjId>, from: usize, to: usize, budget: &mut Option<usize>| {
|
||||
// Split at paragraph ends so every run has one paragraph format.
|
||||
let mut a = from;
|
||||
for &c in cuts.iter().filter(|c| **c > from && **c < to).chain(std::iter::once(&to)) {
|
||||
push_run(r, runs, attrs, characters(&chars, a, c), world, budget, align_at(a));
|
||||
a = c;
|
||||
}
|
||||
};
|
||||
let mut start = 0usize;
|
||||
for run in s.obj(block, b"GAtt").map(|g| s.objs(g, b"Runs")).unwrap_or_default() {
|
||||
let Some(index) = s.int(run, b"Indx").and_then(|v| usize::try_from(v).ok()) else {
|
||||
r.warn("invalid text attribute range (bounded; unassigned characters use defaults)");
|
||||
continue;
|
||||
};
|
||||
let end = index.min(chars.len());
|
||||
if index > chars.len() || end < start {
|
||||
r.warn("invalid text attribute range (bounded; unassigned characters use defaults)");
|
||||
}
|
||||
if end <= start {
|
||||
continue;
|
||||
}
|
||||
push(r, &mut runs, s.obj(run, b"Item"), start, end, &mut attribute_budget);
|
||||
start = end;
|
||||
}
|
||||
if start < chars.len() {
|
||||
push(r, &mut runs, None, start, chars.len(), &mut attribute_budget);
|
||||
}
|
||||
if truncated {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if placeholders {
|
||||
r.warn("text fields such as page numbers (left out of the text)");
|
||||
}
|
||||
(runs, first_align.unwrap_or(Align::Left))
|
||||
}
|
||||
|
||||
fn characters(chars: &[char], start: usize, end: usize) -> String {
|
||||
chars.get(start..end).unwrap_or_default().iter().filter(|c| **c != '\0' && **c != '\u{FFFC}').collect()
|
||||
}
|
||||
|
||||
fn push_run(r: &mut Reader, runs: &mut Vec<TextRun>, attrs: Option<ObjId>, text: String, world: Affine, attribute_budget: &mut Option<usize>) {
|
||||
fn push_run(
|
||||
r: &mut Reader,
|
||||
runs: &mut Vec<TextRun>,
|
||||
attrs: Option<ObjId>,
|
||||
text: String,
|
||||
world: Affine,
|
||||
attribute_budget: &mut Option<usize>,
|
||||
paragraph: Paragraph,
|
||||
) {
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
let run = match attrs {
|
||||
Some(attrs) if attribute_budget.is_some() => run_attrs(r, attrs, text, world, attribute_budget),
|
||||
Some(attrs) if attribute_budget.is_some() => TextRun { paragraph, ..run_attrs(r, attrs, text, world, attribute_budget) },
|
||||
_ => {
|
||||
// This is a recovery policy, not an assertion about unspecified Affinity attributes.
|
||||
if attrs.is_none() {
|
||||
@@ -137,10 +273,15 @@ fn push_run(r: &mut Reader, runs: &mut Vec<TextRun>, attrs: Option<ObjId>, text:
|
||||
italic: false,
|
||||
features: Vec::new(),
|
||||
all_caps: false,
|
||||
underline: false,
|
||||
strikethrough: false,
|
||||
size: 12.0 * r.dpi / 72.0,
|
||||
tracking: 0.0,
|
||||
h_scale: 1.0,
|
||||
leading: None,
|
||||
fill: Paint::Solid(Color::Gray { v: 0.0, a: 1.0 }),
|
||||
stroke: None,
|
||||
paragraph,
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -195,13 +336,22 @@ fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine, attribut
|
||||
fill = Paint::Solid(Color::Gray { v: 0.0, a: 1.0 });
|
||||
}
|
||||
}
|
||||
if attribute_budget.is_some() && objects.get(1).copied().flatten().and_then(|f| paint::descriptor(r, f, world)).is_some_and(|p| p != Paint::None)
|
||||
{
|
||||
r.warn("outlined (stroked) text");
|
||||
}
|
||||
if slot(doubles, 10, float).is_some_and(|h| (h - 1.0).abs() > 1e-3) {
|
||||
r.warn("horizontally scaled text");
|
||||
}
|
||||
// Outlined characters: `Objs` 1 is the outline's paint, 2 its line style (as for shapes).
|
||||
let stroke = match (attribute_budget.is_some(), objects.get(1).copied().flatten(), objects.get(2).copied().flatten()) {
|
||||
(true, Some(pen), Some(style)) => paint::stroke(r, pen, style, Affine::IDENTITY, false),
|
||||
_ => None,
|
||||
};
|
||||
// Underline (slot 0) and strikethrough (slot 1): 1 is a single line.
|
||||
let mut decoration = |i: usize| match slot(ints, i, int) {
|
||||
Some(1) => true,
|
||||
Some(0) | None => false,
|
||||
Some(_) => {
|
||||
r.warn("text decorations of an unknown kind (left out)");
|
||||
false
|
||||
}
|
||||
};
|
||||
let (underline, strikethrough) = (decoration(0), decoration(1));
|
||||
let h_scale = slot(doubles, 10, float).filter(|h| *h > 0.0 && *h < 100.0).unwrap_or(1.0);
|
||||
// A resolved font is populated on the emoji run of the public Affinity 3 text-runs file
|
||||
// (Courier New requested, Segoe UI Emoji stored in RFnt). Empty RFnt records are common.
|
||||
// Source: https://github.com/SethRobinson/Patchy/tree/de84eab550758b30fa062e479f5778cce7693b73/test-fixtures/af
|
||||
@@ -238,10 +388,15 @@ fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine, attribut
|
||||
italic: font.and_then(|f| s.bool(f, b"Ital")).unwrap_or(false),
|
||||
features,
|
||||
all_caps,
|
||||
underline,
|
||||
strikethrough,
|
||||
size,
|
||||
tracking,
|
||||
h_scale,
|
||||
leading,
|
||||
fill,
|
||||
stroke,
|
||||
paragraph: Paragraph::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,13 +458,18 @@ fn font_features(r: &mut Reader, attrs: Option<ObjId>, attribute_budget: &mut Op
|
||||
(features, all_caps)
|
||||
}
|
||||
|
||||
fn paragraph_align(r: &mut Reader, p: ObjId) -> Align {
|
||||
// Nonzero offsets in these slots are present in the public text-indent and text-para-spacing
|
||||
// fixtures. Their layout is not represented by this importer; retain text and report the gap.
|
||||
if (1..=6).any(|i| slot(r.s.field(p, b"Doub"), i, float).is_some_and(|v| v.abs() > 1e-9)) {
|
||||
r.warn("paragraph indents or spacing (imported with defaults)");
|
||||
}
|
||||
match slot(r.s.field(p, b"Ints"), 0, int) {
|
||||
/// A paragraph's alignment, indents, spacing and leading. The `Doub` slots: 0 the automatic
|
||||
/// leading as a multiple of the font's line height (1 in every document checked), 1 fixed leading
|
||||
/// (when `Ints` slot 1 is 2; in every paragraph checked the two go together), 2 indent of the
|
||||
/// lines after the first, 3 right indent, 4 indent of the first line, 5 space before, 6 space
|
||||
/// after. Slots 2, 4, 5 and 6 were identified on the public MIT text-indent and text-para-spacing
|
||||
/// fixtures (Patchy) by measuring their thumbnails, slot 1 by measuring the baseline pitch in the
|
||||
/// thumbnail of a document whose owner made it earlier.
|
||||
fn paragraph(r: &mut Reader, p: ObjId) -> Paragraph {
|
||||
let doubles = r.s.field(p, b"Doub");
|
||||
let ints = r.s.field(p, b"Ints");
|
||||
let at = |i: usize| slot(doubles, i, float).filter(|v| v.abs() < 1e6).unwrap_or(0.0);
|
||||
let align = match slot(ints, 0, int) {
|
||||
Some(1) => Align::Center,
|
||||
Some(2) => Align::Right,
|
||||
Some(3) => Align::Justify,
|
||||
@@ -318,5 +478,29 @@ fn paragraph_align(r: &mut Reader, p: ObjId) -> Align {
|
||||
r.warn("an unknown paragraph alignment (imported as left)");
|
||||
Align::Left
|
||||
}
|
||||
};
|
||||
let leading = match slot(ints, 1, int) {
|
||||
Some(2) => Some(at(1)).filter(|l| *l > 0.0),
|
||||
Some(0) | None => None,
|
||||
Some(_) => {
|
||||
r.warn("paragraph leading of an unknown kind (imported as automatic)");
|
||||
None
|
||||
}
|
||||
};
|
||||
let auto_leading = slot(doubles, 0, float).filter(|v| *v > 0.0 && *v < 100.0).unwrap_or(1.0);
|
||||
Paragraph { align, left: at(2), right: at(3), first: at(4), space_before: at(5).max(0.0), space_after: at(6).max(0.0), leading, auto_leading }
|
||||
}
|
||||
|
||||
/// The scale a text frame or table gives its type (`FTxS`, stored like `Xfrm`): none unless set.
|
||||
/// Neither the frame's own transform nor its groups' scale its type (checked against the
|
||||
/// thumbnails of tables and frames inside scaled groups).
|
||||
fn text_scale(r: &mut Reader, id: ObjId) -> Affine {
|
||||
match r.s.floats::<6>(id, b"FTxS").map(Affine::from_xfrm) {
|
||||
Some(m) if m.is_finite() && m.scale() > 1e-9 => Affine([m.0[0], m.0[1], m.0[2], m.0[3], 0.0, 0.0]),
|
||||
Some(_) => {
|
||||
r.warn("text frames with an invalid text scale (imported unscaled)");
|
||||
Affine::IDENTITY
|
||||
}
|
||||
None => Affine::IDENTITY,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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! {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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];
|
||||
|
||||
@@ -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