Affinity: read native documents with craft-affinity from craft-libs

Affinity: read native documents with craft-affinity from craft-libs

* Affinity: read native documents with craft-affinity from craft-libs

crates/affinity's reader moved to craft-libs as craft-affinity (#914).
The crate keeps its name and re-exports it, so the engine, the corpus
tests and fuzz targets, and the Affinity corpus CI job are unchanged.
Its integration tests take craft-libs' versions; the one signature that
changes is patchy-shapes-2.af, whose special shapes now have outlines.

* Affinity: pin craft-affinity to craft-libs 228f6d0c (no change to the crate)
This commit is contained in:
Dylan M. Taylor
2026-11-12 00:08:38 -05:00
committed by GitHub
parent aa4657e1ce
commit 6d6bbff53b
22 changed files with 107 additions and 6122 deletions
Generated
+12 -11
View File
@@ -967,6 +967,17 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-affinity"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=228f6d0c50f1fdee4c5124603602993fc79d4c9d#228f6d0c50f1fdee4c5124603602993fc79d4c9d"
dependencies = [
"crc32fast",
"flate2",
"image",
"ruzstd",
]
[[package]]
name = "craft-docpath"
version = "0.1.0"
@@ -4879,9 +4890,6 @@ name = "ruzstd"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69e4ce4a039f42bf25920b95667e4d789e98749ab11bbb8d2c2936f788421bb5"
dependencies = [
"twox-hash",
]
[[package]]
name = "ryu"
@@ -6066,12 +6074,6 @@ dependencies = [
"core_maths",
]
[[package]]
name = "twox-hash"
version = "2.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86a801b3cea342a06d468c8710662aa29e5e05e4f5c0d62f00bbb7f2ad7941c2"
[[package]]
name = "type-map"
version = "0.5.1"
@@ -6362,11 +6364,10 @@ dependencies = [
name = "vectorcraft-affinity"
version = "0.24.1"
dependencies = [
"craft-affinity",
"crc32fast",
"flate2",
"image",
"proptest",
"ruzstd",
]
[[package]]
+2
View File
@@ -23,6 +23,8 @@ craft-window = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63
# The `.vectorcraft` envelope: its members, version, embedded images and the data after the JSON.
craft-vectorcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b" }
# craft-update: app-specific pretend versions apply to startup and manual checks.
# Native Affinity documents (craft-libs#914).
craft-affinity = { git = "https://github.com/storytold/craft-libs", rev = "228f6d0c50f1fdee4c5124603602993fc79d4c9d" }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["egui"] }
# internal
vectorcraft-geom = { path = "crates/geom" }
+6 -6
View File
@@ -1,6 +1,6 @@
[package]
name = "vectorcraft-affinity"
description = "Bounded reader for native Affinity documents: archive, object stream and document model"
description = "craft-affinity under its old name, for the corpus tests and fuzz targets"
version.workspace = true
edition.workspace = true
license.workspace = true
@@ -11,13 +11,13 @@ workspace = true
[features]
# Synthetic-container builders for tests and fuzz seeds; not an Affinity exporter.
synth = []
synth = ["craft-affinity/synth"]
[dependencies]
crc32fast = { workspace = true }
flate2 = { workspace = true }
image = { workspace = true }
ruzstd = "0.9.1"
craft-affinity = { workspace = true }
[dev-dependencies]
proptest = { workspace = true }
# The tests build containers and decode previews.
crc32fast = { workspace = true }
image = { workspace = true }
+2
View File
@@ -1,5 +1,7 @@
# vectorcraft-affinity
The reader is [`craft-affinity`](https://github.com/storytold/craft-libs/tree/main/crates/affinity) in craft-libs; this crate re-exports it under its old name for the corpus tests and fuzz targets. The engine maps its document onto VectorCraft art (`crates/engine/src/cmd/fileio/affinity`), as described below.
An independent, bounded Rust reader for native Affinity documents: `.af` from Affinity 3 and
`.afdesign`, `.afphoto` and `.afpub` from Affinity 1 and 2 (container versions 8 to 12). It reads
the archive, the tagged object stream in `doc.dat` and the part of the document model listed below,
-105
View File
@@ -1,105 +0,0 @@
//! Typed reads of [`Stream`] objects. Every accessor returns `None` when a field is missing or has
//! another type, so the document mapping treats odd data as absent rather than failing.
use crate::stream::{ObjId, Object, Stream, Tag, Value};
impl Stream {
pub fn object(&self, id: ObjId) -> Option<&Object> {
self.objects.get(id)
}
pub fn field(&self, id: ObjId, tag: &[u8; 4]) -> Option<&Value> {
self.object(id)?.get(Tag::of(tag))
}
pub fn class(&self, id: ObjId) -> Option<Tag> {
self.object(id).map(|o| o.class)
}
pub fn is(&self, id: ObjId, class: &[u8; 4]) -> bool {
self.class(id) == Some(Tag::of(class))
}
/// A child object (shared, linked or inline); `None` for null or missing.
pub fn obj(&self, id: ObjId, tag: &[u8; 4]) -> Option<ObjId> {
match self.field(id, tag)? {
Value::Obj(o) => *o,
_ => None,
}
}
/// An array of objects (nulls skipped).
pub fn objs(&self, id: ObjId, tag: &[u8; 4]) -> Vec<ObjId> {
match self.field(id, tag) {
Some(Value::Array(a)) => a.iter().filter_map(|v| if let Value::Obj(Some(o)) = v { Some(*o) } else { None }).collect(),
_ => Vec::new(),
}
}
pub fn f64(&self, id: ObjId, tag: &[u8; 4]) -> Option<f64> {
match self.field(id, tag)? {
Value::Float(f) if f.is_finite() => Some(*f),
_ => None,
}
}
pub fn int(&self, id: ObjId, tag: &[u8; 4]) -> Option<i64> {
match self.field(id, tag)? {
Value::Int(i) => Some(*i),
Value::UInt(u) => i64::try_from(*u).ok(),
_ => None,
}
}
pub fn bool(&self, id: ObjId, tag: &[u8; 4]) -> Option<bool> {
match self.field(id, tag)? {
Value::Bool(b) => Some(*b),
_ => None,
}
}
pub fn str(&self, id: ObjId, tag: &[u8; 4]) -> Option<&str> {
match self.field(id, tag)? {
Value::Str(s) => Some(s),
_ => None,
}
}
pub fn enumeration(&self, id: ObjId, tag: &[u8; 4]) -> Option<(u16, u16)> {
match self.field(id, tag)? {
Value::Enum { id, version } => Some((*id, *version)),
_ => None,
}
}
/// A fixed vector of `N` finite floats (points, rectangles, transforms).
pub fn floats<const N: usize>(&self, id: ObjId, tag: &[u8; 4]) -> Option<[f64; N]> {
match self.field(id, tag)? {
Value::Floats(v) if v.len() == N && v.iter().all(|f| f.is_finite()) => {
let mut a = [0.0; N];
a.copy_from_slice(v);
Some(a)
}
_ => None,
}
}
pub fn bytes(&self, id: ObjId, tag: &[u8; 4]) -> Option<&[u8]> {
match self.field(id, tag)? {
Value::Bytes(b) | Value::Blob(b) => Some(b),
_ => None,
}
}
pub fn entry(&self, id: ObjId, tag: &[u8; 4]) -> Option<&str> {
match self.field(id, tag)? {
Value::Entry(s) => Some(s),
_ => None,
}
}
/// Untagged fields of a positional object, in order.
pub fn positional(&self, id: ObjId) -> impl Iterator<Item = &Value> {
self.object(id).into_iter().flat_map(|o| o.fields.iter().map(|(_, v)| v))
}
}
-425
View File
@@ -1,425 +0,0 @@
//! The Affinity archive: header, the chain of file allocation tables (one per save) and
//! bounded, CRC-checked extraction of named entries such as `doc.dat` and raster tiles.
use std::collections::{HashMap, HashSet};
use std::io::Read;
use crate::Error;
pub const MAGIC: &[u8; 4] = b"\x00\xffKA";
/// Oldest and newest container versions seen in real documents (Affinity 1.x writes 8,
/// Affinity 3 writes 12). Version 7 is described by afread but no sample exists.
pub const VERSIONS: std::ops::RangeInclusive<u16> = 8..=12;
/// `Prsn` (stored byte-reversed): documents. Asset, brush and style libraries share the magic.
const DOCUMENT_KIND: &[u8; 4] = b"nsrP";
/// A save appends one table; real files have one to a few hundred.
const MAX_TABLES: usize = 4096;
/// Allocation records read over the whole table chain. Each save lists every entry again, so a
/// document with a few hundred saves of a few thousand entries stays far below; tables whose
/// record regions overlap (or a table chain of thousands of huge tables) can't multiply the work.
const MAX_INDEX_RECORDS: usize = 1 << 22;
/// Bytes of entry names kept by the index (after lossy UTF-8 decoding, which can triple a name).
/// Real names (`doc.dat`, `d/12`) are a few bytes each.
const MAX_INDEX_NAME_BYTES: usize = 16 << 20;
/// zstd window ceiling: real documents use a few MiB; this stops a header from reserving gigabytes.
const MAX_ZSTD_WINDOW: u64 = 64 << 20;
/// Decompression budget for one import. Every entry declares its size, which is checked against
/// these limits before anything is allocated, and the decoded length must match it exactly.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Limits {
/// Largest single entry (`doc.dat` or one tile).
pub max_entry: usize,
/// Total bytes extracted over the whole import.
pub max_total: usize,
}
impl Default for Limits {
fn default() -> Self {
Self { max_entry: 256 << 20, max_total: 1 << 30 }
}
}
/// The container header, all integers little-endian.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Header {
pub version: u16,
/// Seen as 0, 4 or 8 in real files; bits 0 and 1 mark container variants that are rejected.
pub flags: u16,
pub(crate) fat_offset: u64,
pub(crate) thumbnail_offset: u64,
/// Save time in Unix seconds, as written by the application.
pub saved: u32,
}
/// One FAT record, as of the newest table that mentions its id.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Entry {
pub id: u32,
pub name: String,
offset: u64,
size: u64,
compressed: u64,
crc: u32,
compression: u8,
}
impl Entry {
/// Decompressed size declared by the table.
pub fn size(&self) -> u64 {
self.size
}
}
#[derive(Debug)]
pub struct Archive<'a> {
bytes: &'a [u8],
pub header: Header,
/// Head revision of every named, non-deleted entry.
entries: HashMap<String, Entry>,
/// Number of tables (saves) in the chain.
pub revisions: usize,
extracted: usize,
limits: Limits,
/// Entries whose extraction failed after their size was charged: asked again, they fail
/// again without being decompressed again.
failed: HashMap<String, Error>,
}
pub(crate) struct Cursor<'a> {
bytes: &'a [u8],
pub(crate) pos: usize,
}
impl<'a> Cursor<'a> {
pub(crate) fn new(bytes: &'a [u8], pos: usize) -> Self {
Self { bytes, pos }
}
pub(crate) fn remaining(&self) -> usize {
self.bytes.len().saturating_sub(self.pos)
}
pub(crate) fn take(&mut self, n: usize) -> Result<&'a [u8], Error> {
let end = self.pos.checked_add(n).ok_or(Error::Malformed("length overflow"))?;
let s = self.bytes.get(self.pos..end).ok_or(Error::Malformed("truncated data"))?;
self.pos = end;
Ok(s)
}
pub(crate) fn array<const N: usize>(&mut self) -> Result<[u8; N], Error> {
let s = self.take(N)?;
let mut a = [0u8; N];
a.copy_from_slice(s);
Ok(a)
}
pub(crate) fn u8(&mut self) -> Result<u8, Error> {
Ok(self.array::<1>()?[0])
}
pub(crate) fn u16(&mut self) -> Result<u16, Error> {
Ok(u16::from_le_bytes(self.array()?))
}
pub(crate) fn u32(&mut self) -> Result<u32, Error> {
Ok(u32::from_le_bytes(self.array()?))
}
pub(crate) fn u64(&mut self) -> Result<u64, Error> {
Ok(u64::from_le_bytes(self.array()?))
}
}
fn offset(v: u64) -> Result<usize, Error> {
usize::try_from(v).map_err(|_| Error::Malformed("offset beyond addressable memory"))
}
/// True when `bytes` start with the Affinity container signature (documents and libraries).
pub fn is_affinity(bytes: &[u8]) -> bool {
bytes.starts_with(MAGIC)
}
/// Check the signature, version and document kind.
pub fn header(bytes: &[u8]) -> Result<Header, Error> {
if !is_affinity(bytes) {
return Err(Error::Malformed("missing container signature"));
}
let mut c = Cursor::new(bytes, 4);
let version = c.u16()?;
let flags = c.u16()?;
if !VERSIONS.contains(&version) {
return Err(Error::Unsupported("unknown container version"));
}
if flags & 3 != 0 {
return Err(Error::Unsupported("container variant"));
}
if c.take(4)? != DOCUMENT_KIND {
return Err(Error::Unsupported("this Affinity file is a library (assets, brushes or styles), not a document"));
}
if c.take(4)? != b"#Inf" {
return Err(Error::Malformed("missing information header"));
}
let fat_offset = c.u64()?;
let thumbnail_offset = c.u64()?;
c.take(16)?;
let saved = u32::try_from(c.u64()?).unwrap_or(0);
c.take(8)?;
if c.take(4)? != b"Prot" {
return Err(Error::Malformed("missing protocol header"));
}
Ok(Header { version, flags, fat_offset, thumbnail_offset, saved })
}
impl<'a> Archive<'a> {
pub fn open(bytes: &'a [u8], limits: Limits) -> Result<Self, Error> {
Self::open_within(bytes, limits, MAX_INDEX_RECORDS, MAX_INDEX_NAME_BYTES)
}
/// [`Archive::open`] reading at most `max_records` allocation records and keeping at most
/// `max_name_bytes` bytes of entry names, both over the whole table chain.
pub(crate) fn open_within(bytes: &'a [u8], limits: Limits, max_records: usize, max_name_bytes: usize) -> Result<Self, Error> {
let header = header(bytes)?;
let mut records_left = max_records;
let mut name_bytes_left = max_name_bytes;
// Newest record per id: (table date, table index, record).
let mut latest: HashMap<u32, (u64, usize, Option<Entry>)> = HashMap::new();
let mut names: HashMap<u32, String> = HashMap::new();
let mut seen = HashSet::new();
let mut next = header.fat_offset;
let mut tables = 0usize;
while next != 0 {
if !seen.insert(next) {
return Err(Error::Malformed("allocation tables form a cycle"));
}
tables += 1;
if tables > MAX_TABLES {
return Err(Error::Limit("too many saved revisions"));
}
let mut c = Cursor::new(bytes, offset(next)?);
let tag = c.array::<4>()?;
let level = match &tag {
b"#FAT" => 1,
b"#FT2" => 2,
b"#FT3" => 3,
b"#FT4" => 4,
_ => return Err(Error::Malformed("missing allocation table")),
};
next = c.u64()?;
let date = c.u64()?;
c.take(24)?;
let files = c.u32()?;
c.take(8)?;
let dirs = c.u16()?;
c.take(1)?;
for _ in 0..files {
records_left = records_left.checked_sub(1).ok_or(Error::Limit("too many archive entries"))?;
let id = c.u32()?;
let flag = c.u8()?;
let mut record = None;
match flag {
0 | 1 => {
let offset = c.u64()?;
let size = c.u64()?;
let compressed = c.u64()?;
let crc = c.u32()?;
let stored = c.u8()?;
if level >= 2 {
c.take(4)?;
}
if level >= 4 {
c.take(4)?;
}
// Old tables number the methods; newer ones store the method byte itself.
let compression = if level <= 2 {
match stored {
1 => 0x01,
2 => 0x41,
3 => 0x81,
4 => 0xC1,
_ => 0,
}
} else {
stored
};
record = Some(Entry { id, name: String::new(), offset, size, compressed, crc, compression });
}
2 => {}
_ => return Err(Error::Malformed("unknown allocation record")),
}
if flag == 0 {
let len = usize::from(c.u16()?);
let raw = c.take(len)?;
// Only the first name of an id is kept: later ones are not even decoded.
if let std::collections::hash_map::Entry::Vacant(slot) = names.entry(id) {
let name = String::from_utf8_lossy(raw);
name_bytes_left = name_bytes_left.checked_sub(name.len()).ok_or(Error::Limit("archive entry names too long"))?;
slot.insert(name.into_owned());
}
}
// Equal dates: the first table in the chain (the newest) wins, as in afread.
let replace = latest.get(&id).is_none_or(|(d, _, _)| date > *d);
if replace {
latest.insert(id, (date, tables, record));
}
}
for _ in 0..dirs {
records_left = records_left.checked_sub(1).ok_or(Error::Limit("too many archive entries"))?;
let len = usize::from(c.u16()?);
c.take(10)?;
c.take(len)?;
}
}
let mut entries = HashMap::new();
for (id, (_, _, record)) in latest {
if let (Some(mut e), Some(name)) = (record, names.remove(&id)) {
e.name = name;
entries.insert(e.name.clone(), e);
}
}
Ok(Self { bytes, header, entries, revisions: tables, extracted: 0, limits, failed: HashMap::new() })
}
pub fn entry(&self, name: &str) -> Option<&Entry> {
self.entries.get(name)
}
pub fn entry_names(&self) -> impl Iterator<Item = &str> {
self.entries.keys().map(String::as_str)
}
/// Bytes extracted so far, counted against [`Limits::max_total`].
pub fn extracted(&self) -> usize {
self.extracted
}
/// Extract an entry by name: decompress, undo the byte predictor and verify the CRC.
/// 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> {
if let Some(e) = self.failed.get(name) {
return Err(*e);
}
let e = self.entries.get(name).ok_or(Error::Malformed("missing archive entry"))?;
let size = usize::try_from(e.size).map_err(|_| Error::Limit("archive entry too large"))?;
if size > self.limits.max_entry {
return Err(Error::Limit("archive entry too large"));
}
let total = self.extracted.checked_add(size).ok_or(Error::Limit("document too large"))?;
if total > self.limits.max_total {
return Err(Error::Limit("document too large"));
}
// Charged before the work, and kept when it fails: an entry that fails its checksum
// can't be decompressed over and over for free.
self.extracted = total;
let result = extract(self.bytes, e, size);
if let Err(err) = result {
self.failed.insert(name.to_owned(), err);
}
result
}
}
/// Decompress entry `e` of `bytes` (declared `size` bytes), undo its predictor and check its CRC.
fn extract(bytes: &[u8], e: &Entry, size: usize) -> Result<Vec<u8>, Error> {
let mut c = Cursor::new(bytes, offset(e.offset)?);
if c.take(4)? != b"#Fil" {
return Err(Error::Malformed("archive entry has no data block"));
}
let method = e.compression & 3;
let stored_len = if matches!(method, 1 | 2) { e.compressed } else { e.size };
let data = c.take(offset(stored_len)?)?;
let mut out = match method {
1 => inflate(data, size)?,
2 => unzstd(data, size)?,
_ => data.to_vec(),
};
if out.len() != size {
return Err(Error::Malformed("archive entry size mismatch"));
}
unpredict(&mut out, e.compression)?;
if crc32fast::hash(&out) != e.crc {
return Err(Error::Malformed("archive entry checksum mismatch"));
}
Ok(out)
}
/// Read at most `size + 1` bytes so a stream that decodes past its declared size is caught
/// without unbounded allocation.
fn read_bounded(mut r: impl Read, size: usize) -> Result<Vec<u8>, Error> {
let mut out = Vec::with_capacity(size.min(64 << 20));
let cap = u64::try_from(size).unwrap_or(u64::MAX).saturating_add(1);
r.by_ref().take(cap).read_to_end(&mut out).map_err(|_| Error::Malformed("corrupt compressed entry"))?;
Ok(out)
}
fn inflate(data: &[u8], size: usize) -> Result<Vec<u8>, Error> {
read_bounded(flate2::read::ZlibDecoder::new(data), size)
}
fn unzstd(data: &[u8], size: usize) -> Result<Vec<u8>, Error> {
let mut src = data;
let mut out = Vec::new();
// Entries may hold several concatenated frames.
while !src.is_empty() {
let left = size.checked_sub(out.len()).ok_or(Error::Malformed("archive entry size mismatch"))?;
let decoder = ruzstd::decoding::StreamingDecoder::new_with_max_window_size(&mut src, MAX_ZSTD_WINDOW)
.map_err(|_| Error::Malformed("corrupt compressed entry"))?;
let frame = read_bounded(decoder, left)?;
if frame.is_empty() {
return Err(Error::Malformed("corrupt compressed entry"));
}
out.extend_from_slice(&frame);
if out.len() > size {
return Err(Error::Malformed("archive entry size mismatch"));
}
}
Ok(out)
}
/// Undo the byte predictor selected by the high bits of the compression byte.
fn unpredict(b: &mut [u8], compression: u8) -> Result<(), Error> {
match (compression & 0xC0, compression & 0x20 != 0) {
(0x00, _) => {}
(0x40, _) => byte_delta(b),
(0x80, false) => {
let mut prev = 0u16;
for pair in b.as_chunks_mut::<2>().0.iter_mut() {
let v = u16::from_le_bytes([pair[0], pair[1]]).wrapping_add(prev);
pair.copy_from_slice(&v.to_le_bytes());
prev = v;
}
}
(0x80, true) => {
byte_delta(b);
// A 64 KiB tile of 16-bit samples is stored as all high bytes, then all low bytes.
if b.len() == 0x10000 {
let planes = b.to_vec();
let (hi, lo) = planes.split_at(0x8000);
for (i, out) in b.as_chunks_mut::<4>().0.iter_mut().enumerate() {
let (Some(h), Some(l)) = (hi.get(2 * i..2 * i + 2), lo.get(2 * i..2 * i + 2)) else {
return Err(Error::Malformed("tile size"));
};
out.copy_from_slice(&[l[0], h[0], l[1], h[1]]);
}
}
}
_ => return Err(Error::Unsupported("archive entry predictor")),
}
Ok(())
}
fn byte_delta(b: &mut [u8]) {
let mut prev = 0u8;
for v in b {
*v = v.wrapping_add(prev);
prev = *v;
}
}
-360
View File
@@ -1,360 +0,0 @@
//! Outlines of curves (`PCvD` node lists), parametric shapes (`Shpe` objects) and compounds, in
//! node space.
use crate::model::{Affine, Path, Point, Reader, SubPath};
use crate::stream::{ObjId, Tag, Value};
/// Nodes per curve object.
const MAX_NODES: usize = 4_000_000;
/// A shared-object stream is bounded, but a compound graph can expand exponentially. Count
/// each visit and each curve record / generated contour / transformed segment across the whole
/// outline, including repeated references. Match the document reader's nesting limit.
const MAX_OUTLINE_DEPTH: usize = 128;
const MAX_OUTLINE_VISITS: usize = 100_000;
struct OutlineBudget {
active: Vec<ObjId>,
visits: usize,
work: usize,
failed: bool,
}
impl OutlineBudget {
fn fail(&mut self, r: &mut Reader, warning: &'static str) -> Option<()> {
if !self.failed {
r.warn(warning);
}
self.failed = true;
None
}
fn spend(&mut self, r: &mut Reader, work: usize) -> Option<()> {
if work > self.work {
return self.fail(r, "compound outlines exceeding the geometry work limit");
}
self.work -= work;
Some(())
}
fn path(&mut self, r: &mut Reader, path: &Path) -> Option<()> {
for subpath in &path.subpaths {
self.spend(r, 1)?;
self.spend(r, subpath.segments.len())?;
}
Some(())
}
}
/// The node-space outline of a curve, shape or compound node, and whether it fills even-odd.
/// Instances (master-page or symbol copies) take the geometry of their master.
pub(crate) fn outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine) -> Option<(Path, bool)> {
let mut budget = OutlineBudget { active: Vec::new(), visits: MAX_OUTLINE_VISITS, work: MAX_NODES, failed: false };
bounded_outline(r, id, class, world, &mut budget)
}
fn bounded_outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine, budget: &mut OutlineBudget) -> Option<(Path, bool)> {
if budget.active.contains(&id) {
budget.fail(r, "a compound outline that contains itself")?;
}
if budget.active.len() >= MAX_OUTLINE_DEPTH {
budget.fail(r, "compound outlines nested deeper than 128 levels")?;
}
if budget.visits == 0 {
budget.fail(r, "compound outlines exceeding the operand visit limit")?;
}
budget.visits -= 1;
budget.active.push(id);
let result = outline_inner(r, id, class, world, budget);
// The popped value is our own stack marker, and is no longer needed.
let _ = budget.active.pop();
result
}
fn outline_inner(r: &mut Reader, id: ObjId, class: Tag, world: Affine, budget: &mut OutlineBudget) -> Option<(Path, bool)> {
let s = r.s;
match &class.0.to_be_bytes() {
b"PCrv" | b"TxtC" => {
let crvs = s.obj(id, b"Crvs").or_else(|| s.obj(id, b"MCrM").and_then(|m| s.obj(m, b"Crvs")))?;
let path = curves(r, crvs, budget)?;
budget.path(r, &path)?;
let multi = path.subpaths.len() > 1;
Some((path, multi))
}
b"Comp" => {
// One path of all the operands' contours in the compound's space, filled even-odd: this
// matches add and subtract operands in every public sample; a true union of overlapping
// added operands would differ.
let mut path = Path::default();
for c in s.objs(id, b"Chld") {
let local = s.floats::<6>(c, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY);
let class = s.class(c)?;
if s.enumeration(c, b"ComO").is_some_and(|(op, _)| op != 0 && op != 2) {
r.warn("compound shapes that intersect or exclude (imported as add)");
}
let child_world = local.then(world);
if !child_world.is_finite() {
budget.fail(r, "a compound operand with an invalid transform")?;
}
if let Some((mut p, _)) = bounded_outline(r, c, class, child_world, budget) {
// Charge before transforming or extending. Transform the owned path in place
// so nested compounds do not make an additional copy of every contour.
budget.path(r, &p)?;
for subpath in &mut p.subpaths {
subpath.start = local.apply(subpath.start);
for segment in &mut subpath.segments {
*segment = segment.map(|point| local.apply(point));
}
}
path.subpaths.extend(p.subpaths);
} else if budget.failed {
// Never present a partial compound as complete after a cycle or budget limit.
return None;
}
}
Some((path, true))
}
_ => {
let (shape, box_owner) = match s.obj(id, b"Shpe") {
Some(sh) => (sh, id),
None => {
let master = s.obj(id, b"ShpM")?;
(s.obj(master, b"Shpe")?, master)
}
};
let b = s.floats::<4>(box_owner, b"ShpB")?;
let path = crate::shapes::shape(r, shape, b, world)?;
budget.path(r, &path)?;
Some((path, false))
}
}
}
/// `PCvD.Data`: positional `[u8, u32 count, then per subpath: bool closed, 18-byte records]`.
/// A record is (f64 x, f64 y, u8 kind, u8 role): role 0 is an anchor, 1 the out-handle of the
/// anchor before it, 2 the in-handle of the anchor after it. A closed subpath repeats its first
/// anchor at the end. `CnrD` live corners are rounded on the stored polyline.
fn curves(r: &mut Reader, crvs: ObjId, budget: &mut OutlineBudget) -> Option<Path> {
let s = r.s;
let data = s.obj(crvs, b"Data")?;
let mut fields = s.positional(data);
fields.next()?;
let count = match fields.next()? {
Value::UInt(n) => usize::try_from(*n).ok()?,
_ => return None,
};
budget.spend(r, count)?;
let corners = s.obj(crvs, b"CnrD").and_then(|c| corner_radii(s.positional(c).collect()));
let mut path = Path::default();
let mut seen = 0usize;
for index in 0..count {
let closed = match fields.next()? {
Value::Bool(b) => *b,
_ => return None,
};
let Value::Array(records) = fields.next()? else { return None };
seen = seen.checked_add(records.len())?;
if seen > MAX_NODES {
budget.fail(r, "curves with more than four million nodes")?;
}
budget.spend(r, records.len())?;
let anchors = anchors(records)?;
let radii = if index == 0 { corners.as_ref() } else { None };
if let Some(sp) = subpath(&anchors, closed, radii) {
path.subpaths.push(sp);
}
}
if corners.is_some() && count > 1 {
r.warn("rounded corners on curves with several subpaths (only the first is rounded)");
}
Some(path)
}
/// Most segments a stroke's pressure curve may have; the curves in real documents have a handful.
const MAX_PRESSURE_SEGMENTS: usize = 256;
/// Most regular samples of a pressure curve: each segment gets an equal share, at least 2 (its
/// middle and its end) and 8 at most. The points where a segment's width stops rising or falling
/// come on top (at most two per segment), so a curve has at most 1 + 4 × [`MAX_PRESSURE_SEGMENTS`]
/// points; the document's limit counts all of them.
const PRESSURE_POINTS: usize = 512;
/// Most points one pressure curve can have (see [`PRESSURE_POINTS`]). A curve that can't be read
/// counts this many against the document's limit.
pub(crate) const MAX_PRESSURE_CURVE_POINTS: usize = 1 + 4 * MAX_PRESSURE_SEGMENTS;
/// A stroke's pressure curve (`LDsc.LDeP`, a `CrvD` whose positional `Data` holds an array of
/// 18-byte records as in [`curves`]): x is the position along the path (0 to 1) and y the width as
/// a fraction of the stroke's weight. Each segment is sampled at 2 to 8 points, at most
/// [`PRESSURE_POINTS`] in all, and also where its width stops rising or falling (at most two more
/// points), so a dip between samples is kept → (position, width) in order along the path,
/// positions clamped into 0..1 and never going back, widths clamped into 0..1. `None` when the
/// curve can't be read or has more than [`MAX_PRESSURE_SEGMENTS`] segments.
pub(crate) fn pressure(r: &Reader, crvd: ObjId) -> Option<Vec<(f64, f64)>> {
let s = r.s;
let data = s.obj(crvd, b"Data")?;
let records = s.positional(data).find_map(|v| if let Value::Array(a) = v { Some(a) } else { None })?;
// Every segment takes at least an anchor record.
if records.len() > MAX_PRESSURE_SEGMENTS * 3 + 1 {
return None;
}
let curve = subpath(&anchors(records)?, false, None)?;
if curve.segments.len() > MAX_PRESSURE_SEGMENTS {
return None;
}
let steps = u32::try_from((PRESSURE_POINTS / curve.segments.len().max(1)).clamp(2, 8)).unwrap_or(2);
let mut samples = vec![(curve.start.x, curve.start.y)];
let mut from = curve.start;
for [c1, c2, to] in &curve.segments {
let mut ts: Vec<f64> = (1..=steps).map(|i| f64::from(i) / f64::from(steps)).collect();
ts.extend(turning_points(from.y, c1.y, c2.y, to.y).into_iter().flatten());
ts.sort_by(f64::total_cmp);
ts.dedup_by(|a, b| (*a - *b).abs() < 1e-9);
for t in ts {
let u = 1.0 - t;
let at = |a: f64, b: f64, c: f64, d: f64| u * u * u * a + 3.0 * u * u * t * b + 3.0 * u * t * t * c + t * t * t * d;
samples.push((at(from.x, c1.x, c2.x, to.x), at(from.y, c1.y, c2.y, to.y)));
}
from = *to;
}
let mut last = 0.0f64;
Some(
samples
.into_iter()
.map(|(x, y)| {
last = x.clamp(last, 1.0);
(last, y.clamp(0.0, 1.0))
})
.collect(),
)
}
/// The parameters strictly between 0 and 1 where the cubic Bézier with control values `a`, `b`,
/// `c` and `d` stops rising or falling (where its derivative is 0). The values are scaled into
/// -1..1 first, so huge ones can't overflow the discriminant.
fn turning_points(a: f64, b: f64, c: f64, d: f64) -> [Option<f64>; 2] {
let scale = a.abs().max(b.abs()).max(c.abs()).max(d.abs());
if !(scale.is_finite() && scale > 0.0) {
return [None, None];
}
let (a, b, c, d) = (a / scale, b / scale, c / scale, d / scale);
let (d0, d1, d2) = (b - a, c - b, d - c);
let (qa, qb, qc) = (d0 - 2.0 * d1 + d2, 2.0 * (d1 - d0), d0);
let inside = |t: f64| (t.is_finite() && t > 0.0 && t < 1.0).then_some(t);
if qa.abs() < 1e-12 {
return [if qb.abs() < 1e-12 { None } else { inside(-qc / qb) }, None];
}
let disc = qb * qb - 4.0 * qa * qc;
if disc < 0.0 {
return [None, None];
}
let root = disc.sqrt();
[inside((-qb - root) / (2.0 * qa)), inside((-qb + root) / (2.0 * qa))]
}
struct Anchor {
p: Point,
inh: Option<Point>,
out: Option<Point>,
/// Index of the anchor's record (live corners refer to records).
record: usize,
}
fn record(v: &Value) -> Option<(Point, u8)> {
let Value::Bytes(b) = v else { return None };
if b.len() != 18 {
return None;
}
let x = f64::from_le_bytes(b.get(0..8)?.try_into().ok()?);
let y = f64::from_le_bytes(b.get(8..16)?.try_into().ok()?);
if !x.is_finite() || !y.is_finite() {
return None;
}
Some((Point { x, y }, *b.get(17)?))
}
fn anchors(records: &[Value]) -> Option<Vec<Anchor>> {
let mut out: Vec<Anchor> = Vec::with_capacity(records.len() / 3 + 1);
let mut pending_in = None;
for (i, v) in records.iter().enumerate() {
let (p, role) = record(v)?;
match role {
0 => {
out.push(Anchor { p, inh: pending_in.take(), out: None, record: i });
}
1 => {
// A leading out-handle without an anchor is edit state only.
if let Some(a) = out.last_mut() {
a.out = Some(p);
}
}
2 => pending_in = Some(p),
_ => return None,
}
}
Some(out)
}
fn subpath(anchors: &[Anchor], closed: bool, radii: Option<&Vec<(usize, f64)>>) -> Option<SubPath> {
let first = anchors.first()?;
if let Some(radii) = radii.filter(|r| !r.is_empty()) {
// The largest radius of each record, once (not a scan of all of them for each anchor).
let mut largest: std::collections::HashMap<usize, f64> = std::collections::HashMap::new();
for (rec, r) in radii {
let e = largest.entry(*rec).or_insert(0.0);
*e = e.max(*r);
}
let corner = |a: &Anchor| largest.get(&a.record).copied().unwrap_or(0.0);
let polyline = anchors.iter().all(|a| a.inh.is_none_or(|h| h == a.p) && a.out.is_none_or(|h| h == a.p));
if polyline {
let mut pts: Vec<(Point, f64)> = anchors.iter().map(|a| (a.p, corner(a))).collect();
// A closed subpath repeats its first anchor; its radius may be on either copy.
if closed
&& pts.len() > 1
&& pts.first().map(|p| p.0) == pts.last().map(|p| p.0)
&& let Some((_, r)) = pts.pop()
&& let Some(f) = pts.first_mut()
{
f.1 = f.1.max(r);
}
return Some(crate::shapes::fillet(&pts, closed));
}
}
let segments = anchors
.windows(2)
.filter_map(|w| {
let [a, b] = w else { return None };
Some([a.out.unwrap_or(a.p), b.inh.unwrap_or(b.p), b.p])
})
.collect();
Some(SubPath { start: first.p, segments, closed })
}
/// `CnrD`: positional `[u8, i32[] record indices, f64[] radii, u8[] types]`.
fn corner_radii(fields: Vec<&Value>) -> Option<Vec<(usize, f64)>> {
let (Some(Value::Array(idx)), Some(Value::Array(rad))) = (fields.get(1), fields.get(2)) else { return None };
Some(
idx.iter()
.zip(rad)
.filter_map(|(i, r)| match (i, r) {
(Value::Int(i), Value::Float(r)) if r.is_finite() && *r > 0.0 => Some((usize::try_from(*i).ok()?, *r)),
_ => None,
})
.collect(),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn turning_points_of_huge_control_values_are_found() {
let small = turning_points(1.0, -3.0, 3.0, 1.0);
let huge = turning_points(1e200, -3e200, 3e200, 1e200);
assert!(small.iter().all(Option::is_some), "{small:?}");
for (s, h) in small.iter().zip(&huge) {
let (Some(s), Some(h)) = (s, h) else { panic!("{huge:?}") };
assert!((s - h).abs() < 1e-12, "{small:?} {huge:?}");
}
}
}
+3 -49
View File
@@ -1,52 +1,6 @@
//! Reader for native Affinity documents (`.af`, and `.afdesign`/`.afphoto`/`.afpub` from
//! Affinity 1 and 2): the archive container, the tagged object stream in `doc.dat`, and the
//! subset of the document model VectorCraft imports.
//!
//! Layouts follow the MIT-licensed afread reference and were checked against public documents
//! saved by Affinity 1.x to 3.x (container versions 8 to 12); see the crate README for provenance
//! and the exact supported subset. Nothing here writes Affinity files.
//! The Affinity reader is `craft-affinity` (storytold/craft-libs). This crate keeps its name so
//! the corpus tests and fuzz targets under it, and the CI job that runs them, still build.
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use std::fmt;
mod access;
pub mod container;
mod geometry;
pub mod model;
pub mod overlay;
pub mod paint;
pub mod preview;
mod raster;
mod shapes;
pub mod stream;
#[cfg(any(test, feature = "synth"))]
pub mod synth;
mod text;
pub use container::{Archive, Limits, MAGIC, is_affinity};
pub use model::{Document, read};
pub use preview::{MAX_PREVIEW_BYTES, MAX_PREVIEW_DIMENSION, Preview, preview};
/// Why a file could not be read. Messages are short reasons; callers add advice.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Error {
Malformed(&'static str),
Unsupported(&'static str),
Limit(&'static str),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Malformed(s) => write!(f, "damaged Affinity file: {s}"),
Self::Unsupported(s) => write!(f, "unsupported Affinity file: {s}"),
Self::Limit(s) => write!(f, "Affinity file exceeds a safety limit: {s}"),
}
}
}
impl std::error::Error for Error {}
#[cfg(test)]
mod tests;
pub use craft_affinity::*;
-471
View File
@@ -1,471 +0,0 @@
//! 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.
use std::collections::{BTreeMap, HashSet};
use crate::paint::{self, Paint, Stroke};
use crate::stream::{ObjId, Stream, Tag};
use crate::{Archive, Error, Limits, stream};
mod bounds;
mod read;
pub(crate) use bounds::rect;
pub use bounds::{kind_bounds, nodes_bounds};
pub(crate) use read::Reader;
pub use read::read;
#[cfg(test)]
pub(crate) use read::read_within;
/// Affine map `x' = a·x + c·y + e`, `y' = b·x + d·y + f` (kurbo's coefficient order).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Affine(pub [f64; 6]);
impl Affine {
pub const IDENTITY: Self = Self([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
/// Affinity's `Xfrm`, stored row by row: (a, c, e, b, d, f).
pub(crate) fn from_xfrm(m: [f64; 6]) -> Self {
Self([m[0], m[3], m[1], m[4], m[2], m[5]])
}
pub fn then(self, outer: Self) -> Self {
let [a, b, c, d, e, f] = self.0;
let [oa, ob, oc, od, oe, of] = outer.0;
Self([oa * a + oc * b, ob * a + od * b, oa * c + oc * d, ob * c + od * d, oa * e + oc * f + oe, ob * e + od * f + of])
}
pub fn apply(&self, p: Point) -> Point {
let [a, b, c, d, e, f] = self.0;
Point { x: a * p.x + c * p.y + e, y: b * p.x + d * p.y + f }
}
/// Geometric mean of the axis scales: how much a stroke width or font size grows.
pub fn scale(&self) -> f64 {
let [a, b, c, d, ..] = self.0;
(a * d - b * c).abs().sqrt()
}
pub fn is_finite(&self) -> bool {
self.0.iter().all(|v| v.is_finite())
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Point {
pub x: f64,
pub y: f64,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Rect {
pub x0: f64,
pub y0: f64,
pub x1: f64,
pub y1: f64,
}
/// One cubic Bézier subpath, in document pixels.
#[derive(Debug, Clone, PartialEq)]
pub struct SubPath {
pub start: Point,
/// (control 1, control 2, end) per segment.
pub segments: Vec<[Point; 3]>,
pub closed: bool,
}
/// An outline in document pixels.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Path {
pub subpaths: Vec<SubPath>,
}
impl Path {
pub fn transformed(&self, m: Affine) -> Self {
Self {
subpaths: self
.subpaths
.iter()
.map(|s| SubPath {
start: m.apply(s.start),
segments: s.segments.iter().map(|seg| seg.map(|p| m.apply(p))).collect(),
closed: s.closed,
})
.collect(),
}
}
}
/// Affinity's blend modes, numbered by the `Blnd` enum.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Blend {
#[default]
Normal,
PassThrough,
Darken,
DarkerColor,
Multiply,
ColorBurn,
Lighten,
LighterColor,
Screen,
ColorDodge,
Add,
Overlay,
SoftLight,
HardLight,
VividLight,
PinLight,
LinearLight,
HardMix,
Difference,
Exclusion,
Subtract,
Hue,
Saturation,
Luminosity,
Color,
Average,
Negation,
Reflect,
Glow,
Erase,
}
impl Blend {
/// The enum (id, version) pairs observed in a document whose layers are named after their mode.
fn from_enum(id: u16, version: u16) -> Option<Self> {
use Blend::*;
Some(match (id, version) {
(0, _) => Normal,
(1, 0) => Darken,
(2, 1) => DarkerColor,
(2, 0) => Multiply,
(3, 0) => ColorBurn,
(4, 0) => Lighten,
(6, 1) => LighterColor,
(5, 0) => Screen,
(6, 0) => ColorDodge,
(7, 0) => Add,
(8, 0) => Overlay,
(9, 0) => SoftLight,
(10, 0) => HardLight,
(11, 0) => VividLight,
(12, 0) => PinLight,
(15, 1) => LinearLight,
(13, 0) => HardMix,
(14, 0) => Difference,
(15, 0) => Exclusion,
(16, 0) => Subtract,
(17, 0) => Hue,
(18, 0) => Saturation,
(19, 0) => Luminosity,
(20, 0) => Color,
(21, 0) => Average,
(22, 0) => Negation,
(23, 0) => Reflect,
(24, 0) => Glow,
(25, 0) => Erase,
_ => return None,
})
}
}
/// A run of text with one set of character attributes.
#[derive(Debug, Clone, PartialEq)]
pub struct TextRun {
pub text: String,
/// PostScript name and family of the chosen font.
pub postscript: String,
pub family: String,
/// CSS weight (400 regular, 700 bold).
pub weight: i64,
pub italic: bool,
/// 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,
}
/// 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,
Justify,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Text {
pub runs: Vec<TextRun>,
pub align: Align,
/// Artistic text: the first baseline's anchor point in node space (start, centre or end of the
/// line according to `align`). Frame text: the frame box in node space.
pub anchor: Point,
pub frame: Option<Rect>,
/// Node space to document pixels.
pub transform: Affine,
/// Node space to document pixels for the type (its linear part matters): artistic text scales
/// with its node, frame text only with its frame's text scale.
pub text_transform: Affine,
}
/// Vertical alignment of a table cell's text.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VAlign {
Top,
Center,
Bottom,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TableCell {
pub runs: Vec<TextRun>,
/// Space between the cell edges and its text, in node space: left, top, right, bottom.
pub inset: [f64; 4],
pub valign: VAlign,
/// Part of the cell to its left (merged): it has no text of its own.
pub merged_left: bool,
}
/// A table: a grid of cells with text, and a line on every cell edge.
#[derive(Debug, Clone, PartialEq)]
pub struct Table {
/// Column boundaries (one more than the columns), increasing, in node space.
pub columns: Vec<f64>,
/// Row boundaries (one more than the rows), increasing, in node space.
pub rows: Vec<f64>,
/// Row by row.
pub cells: Vec<TableCell>,
/// Vertical edges row by row, one more per row than the columns; `None` draws no line, and
/// the list is empty when the file's didn't match the grid.
pub vertical: Vec<Option<Stroke>>,
/// Horizontal edges boundary by boundary (one more than the rows), one per column (empty
/// as `vertical` can be).
pub horizontal: Vec<Option<Stroke>>,
/// Node space to document pixels.
pub transform: Affine,
/// Node space to document pixels for the cells' type: resizing a table moves its grid, not
/// its type.
pub text_transform: Affine,
}
impl Table {
pub fn ncols(&self) -> usize {
self.columns.len().saturating_sub(1)
}
pub fn nrows(&self) -> usize {
self.rows.len().saturating_sub(1)
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum Pixels {
/// Straight-alpha RGBA, 8 bits per channel, row by row.
Rgba8(Vec<u8>),
/// A CMYK pixel layer: cyan, magenta, yellow and black ink amounts (0 none, 255 full) and
/// straight alpha, 8 bits each, row by row. Left in CMYK for the importer to show through a
/// colour profile: the profile-free formula turns the extra cyan of a separation's neutrals
/// into a blue cast.
Cmyka8(Vec<u8>),
/// The original file a placed image was made from (PNG, JPEG…), as embedded.
Encoded(Vec<u8>),
}
#[derive(Debug, Clone, PartialEq)]
pub struct Image {
pub width: u32,
pub height: u32,
pub pixels: Pixels,
/// Pixel space (0..w, 0..h) to document pixels.
pub transform: Affine,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Kind {
/// Layer container (`Scop`).
Layer,
Group,
/// Artboard: an export rectangle and vector outline that clips its children.
/// `background` paints behind them, and `strokes` over them.
Artboard {
rect: Rect,
/// Actual clipping outline in document pixels. The artboard's export rectangle is its
/// bounding box, but a rotated or non-rectangular board still clips to this outline.
path: Path,
even_odd: bool,
background: Vec<Paint>,
strokes: Vec<Stroke>,
},
/// Curve, parametric shape or compound shape: an outline with its fills and strokes.
/// Children of a shape are clipped by its outline (painted over its fills, under its strokes).
Shape {
path: Path,
fills: Vec<Paint>,
strokes: Vec<Stroke>,
even_odd: bool,
},
Text(Text),
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,
pub name: String,
pub visible: bool,
pub locked: bool,
pub opacity: f64,
pub blend: Blend,
pub kind: Kind,
/// Vector mask in document pixels: the node shows only inside it (nonzero fill).
pub mask: Option<Path>,
/// Pixel mask: grey levels (white shows the node, black hides it); outside it the node is hidden.
pub pixel_mask: Option<Image>,
/// Layer effects the importer can show or bake in (others are reported).
pub effects: Vec<Effect>,
/// Bottom to top.
pub children: Vec<Node>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Spread {
/// Spread bounds in document pixels (the canvas, or the union of the artboards).
pub bounds: Rect,
/// Pages of a Publisher-style spread (one, or two facing pages), in document pixels.
pub pages: Vec<Rect>,
/// Transparent background; otherwise the page is white.
pub transparent: bool,
/// Bottom to top.
pub nodes: Vec<Node>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Document {
/// Pixels per inch: all geometry is in document pixels at this resolution.
pub dpi: f64,
pub spreads: Vec<Spread>,
/// 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>>,
}
-142
View File
@@ -1,142 +0,0 @@
use super::read::MAX_TREE_DEPTH;
use super::{Affine, Kind, Node, ObjId, Point, Rect, Stream, Tag};
/// The bounds of what visible `nodes` draw (control points of outlines, corners of images, frames
/// and tables), in document pixels.
pub fn nodes_bounds(nodes: &[Node]) -> Option<Rect> {
let mut out = None;
walk_bounds(nodes, true, 0, &mut out);
out
}
/// The bounds of `node` and everything in it, hidden or not.
pub(super) fn tree_bounds(node: &Node) -> Option<Rect> {
let mut out = None;
walk_bounds(std::slice::from_ref(node), false, 0, &mut out);
out
}
fn walk_bounds(nodes: &[Node], visible_only: bool, depth: usize, out: &mut Option<Rect>) {
if depth > MAX_TREE_DEPTH {
return;
}
for n in nodes.iter().filter(|n| n.visible || !visible_only) {
kind_points(&n.kind, &mut |p| add_point(out, p));
walk_bounds(&n.children, visible_only, depth + 1, out);
}
}
/// What a pixel mask over a node of `kind` with `children` is read over: the bounds of what they
/// draw, strokes included, or `None` (all of the mask) when some of it has no bounds known here:
/// artistic text, whose glyphs only the layout places.
pub(super) fn mask_extent(kind: &Kind, children: &[Node]) -> Option<Rect> {
fn visit(kind: &Kind, children: &[Node], depth: usize, out: &mut Option<Rect>) -> bool {
if depth > MAX_TREE_DEPTH || matches!(kind, Kind::Text(t) if t.frame.is_none()) {
return false;
}
let mut own = kind_bounds(kind);
if let (Some(r), Kind::Shape { strokes, .. } | Kind::Artboard { strokes, .. }) = (own, kind) {
// As far as a stroke can reach out of its path, miter joins included.
let reach = strokes.iter().map(|s| s.width * s.miter_limit.clamp(1.0, 10.0)).filter(|w| w.is_finite()).fold(0.0, f64::max);
own = Some(Rect { x0: r.x0 - reach, y0: r.y0 - reach, x1: r.x1 + reach, y1: r.y1 + reach });
}
if let Some(r) = own {
*out = Some(out.map_or(r, |o| union(o, r)));
}
children.iter().filter(|n| n.visible).all(|n| visit(&n.kind, &n.children, depth + 1, out))
}
let mut out = None;
if visit(kind, children, 0, &mut out) { out } else { None }
}
/// The bounds of what one node's own kind draws (not its children): its outline's points, its
/// image's or frame's corners, or artistic text's anchor.
pub fn kind_bounds(kind: &Kind) -> Option<Rect> {
let mut out = None;
kind_points(kind, &mut |p| add_point(&mut out, p));
out
}
fn add_point(out: &mut Option<Rect>, p: Point) {
if p.x.is_finite() && p.y.is_finite() {
let r = Rect { x0: p.x, y0: p.y, x1: p.x, y1: p.y };
*out = Some(out.map_or(r, |o| union(o, r)));
}
}
fn kind_points(kind: &Kind, add: &mut dyn FnMut(Point)) {
let corners = |r: Rect, m: Affine| [(r.x0, r.y0), (r.x1, r.y0), (r.x1, r.y1), (r.x0, r.y1)].map(|(x, y)| m.apply(Point { x, y }));
match kind {
Kind::Shape { path, .. } | Kind::Artboard { path, .. } => {
for sp in &path.subpaths {
add(sp.start);
for seg in &sp.segments {
seg.iter().for_each(|p| add(*p));
}
}
}
Kind::Image(img) => {
corners(Rect { x0: 0.0, y0: 0.0, x1: f64::from(img.width), y1: f64::from(img.height) }, img.transform).into_iter().for_each(add)
}
Kind::Text(t) => match t.frame {
Some(f) => corners(f, t.transform).into_iter().for_each(add),
None => add(t.transform.apply(t.anchor)),
},
Kind::Table(t) => {
if let (Some(&x0), Some(&x1), Some(&y0), Some(&y1)) = (t.columns.first(), t.columns.last(), t.rows.first(), t.rows.last()) {
corners(Rect { x0, y0, x1, y1 }, t.transform).into_iter().for_each(add);
}
}
Kind::Layer | Kind::Group | Kind::Unsupported | Kind::MasterInstance { .. } => {}
}
}
/// Page rectangles: `SpMd.PagR[].rctp` (Publisher 2, Affinity 3) or `SprB` split into `PagC`
/// equal pages (Publisher 1 facing spreads).
pub(super) fn pages(s: &Stream, spread: ObjId) -> Vec<Rect> {
if let Some(md) = s.obj(spread, b"SpMd") {
let v: Vec<Rect> =
s.objs(md, b"PagR").into_iter().filter_map(|p| s.floats::<4>(p, b"rctp").map(rect)).filter(|r| r.x1 > r.x0 && r.y1 > r.y0).collect();
if !v.is_empty() {
return v;
}
}
match (s.floats::<4>(spread, b"SprB").map(rect), s.int(spread, b"PagC")) {
(Some(b), Some(n @ 2..=16)) => {
let w = (b.x1 - b.x0) / n as f64;
(0..n).map(|i| Rect { x0: b.x0 + w * i as f64, x1: b.x0 + w * (i + 1) as f64, ..b }).collect()
}
_ => Vec::new(),
}
}
pub(crate) fn union(a: Rect, b: Rect) -> Rect {
Rect { x0: a.x0.min(b.x0), y0: a.y0.min(b.y0), x1: a.x1.max(b.x1), y1: a.y1.max(b.y1) }
}
pub(super) fn is_container(class: Tag) -> bool {
class == Tag::of(b"Scop") || class == Tag::of(b"Grup")
}
pub(crate) fn rect(v: [f64; 4]) -> Rect {
Rect { x0: v[0], y0: v[1], x1: v[2], y1: v[3] }
}
/// A readable name for a layer class in warnings.
pub(crate) fn describe(class: Tag) -> &'static str {
match &class.0.to_be_bytes() {
b"PCrv" => "curve",
b"ShpN" | b"SNEN" | b"SNRR" | b"ShRN" => "shape",
b"Comp" => "compound",
b"Rstr" => "pixel",
b"ImgN" => "image",
b"EmbN" => "embedded document",
b"MPIN" => "master page instance",
b"FRst" => "fill",
b"MRst" => "mask",
b"Slic" | b"SlcP" => "slice",
b"3DLA" => "3D",
[_, _, b'R', b'A'] => "adjustment",
_ => "unknown",
}
}
-685
View File
@@ -1,685 +0,0 @@
use super::bounds::{describe, is_container, mask_extent, nodes_bounds, pages, rect, tree_bounds, union};
use super::{
Affine, Archive, BTreeMap, Blend, Document, Effect, Error, HashSet, Image, Kind, Limits, Node, ObjId, Paint, Path, Point, Rect, Spread, Stream,
Tag, paint, stream,
};
/// 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.
pub(super) const MAX_TREE_DEPTH: usize = 128;
/// Total layer nodes imported.
const MAX_NODES: usize = 500_000;
/// Pixels decoded from pixel layers over a whole document (2 GB as RGBA). The archive's limits
/// count the bytes extracted from it, and a layer of one flat colour needs none (#1704).
const MAX_LAYER_PIXELS: u64 = 512 << 20;
/// Embedded documents inside embedded documents are followed this deep.
const MAX_EMBED_DEPTH: usize = 4;
/// Characters of all stories together (a story may be shared by many text layers).
const MAX_DOCUMENT_CHARS: usize = 16 << 20;
/// Table cells of all tables together (each becomes a text object with its edge lines).
const MAX_DOCUMENT_CELLS: usize = 1 << 18;
/// Points of all strokes' pressure curves together (a stroke style may be shared by many layers).
const MAX_DOCUMENT_PRESSURE_POINTS: usize = 1 << 20;
/// Child references inspected, page rectangles produced, and outline segments retained, in all
/// (shared objects are visited once per link, so each visit is charged). Real documents stay
/// within a few times [`MAX_NODES`]; this leaves ~30x headroom over that.
const MAX_DOCUMENT_WORK: usize = 1 << 24;
/// Owned layer-name characters kept in all (each visit of a shared object keeps its own copy).
const MAX_DOCUMENT_NAME_CHARS: usize = 16 << 20;
/// What a whole read may still use. Shared objects can be linked from many layers, and embedded
/// documents are read with what their embedding document left, so linking or embedding the
/// same content many times can't multiply the work.
#[derive(Clone, Copy, Debug)]
pub(crate) struct Budget {
/// Layer nodes ([`MAX_NODES`] in all).
pub(crate) nodes: usize,
/// Pixels the pixel layers may decode ([`MAX_LAYER_PIXELS`] in all).
pub(crate) pixels: u64,
/// Characters of stories ([`MAX_DOCUMENT_CHARS`] in all).
pub(crate) chars: usize,
/// Table cells ([`MAX_DOCUMENT_CELLS`] in all).
pub(crate) cells: usize,
/// Points of pressure curves ([`MAX_DOCUMENT_PRESSURE_POINTS`] in all).
pub(crate) pressure: usize,
/// Child references, page rectangles and outline segments ([`MAX_DOCUMENT_WORK`] in all).
pub(crate) work: usize,
/// Layer-name characters ([`MAX_DOCUMENT_NAME_CHARS`] in all).
pub(crate) name_chars: usize,
}
impl Budget {
pub(crate) const FULL: Budget = Budget {
nodes: MAX_NODES,
pixels: MAX_LAYER_PIXELS,
chars: MAX_DOCUMENT_CHARS,
cells: MAX_DOCUMENT_CELLS,
pressure: MAX_DOCUMENT_PRESSURE_POINTS,
work: MAX_DOCUMENT_WORK,
name_chars: MAX_DOCUMENT_NAME_CHARS,
};
/// Take `n` units of work, or fail the read.
pub(crate) fn spend(&mut self, n: usize) -> Result<(), Error> {
self.work = self.work.checked_sub(n).ok_or(Error::Limit("too much shared geometry or page data"))?;
Ok(())
}
}
/// 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)
}
/// [`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, &mut Budget { pixels, ..Budget::FULL }, Affine::IDENTITY, 0).document
}
/// A document read by [`read_at`] (or why it couldn't be), with the bytes its archive extracted
/// either way.
struct ReadAt {
document: Result<Document, Error>,
extracted: usize,
}
/// Read a document whose spreads are placed by `base` (for embedded documents, nested `depth`
/// deep). What it uses is taken from `budget`, also when it fails part of the way.
/// The most characters of a layer's name kept.
const MAX_NAME_CHARS: usize = 1024;
fn read_at(bytes: &[u8], limits: Limits, budget: &mut Budget, base: Affine, depth: usize) -> ReadAt {
let mut archive = match Archive::open(bytes, limits) {
Ok(a) => a,
Err(e) => return ReadAt { document: Err(e), extracted: 0 },
};
let document = read_archive(&mut archive, budget, base, depth);
ReadAt { document, extracted: archive.extracted() }
}
fn read_archive(archive: &mut Archive, budget: &mut Budget, base: Affine, depth: usize) -> Result<Document, Error> {
let doc = archive.read("doc.dat")?;
let s = stream::parse(&doc)?;
let mut reader = Reader::new(&s, archive, base, depth);
reader.budget = *budget;
let document = reader.document();
*budget = reader.budget;
document
}
pub(crate) struct Reader<'s, 'a, 'b> {
pub(crate) s: &'s Stream,
pub(crate) archive: &'b mut Archive<'a>,
pub(crate) dpi: f64,
pub(crate) warnings: BTreeMap<String, 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>,
/// What the rest of the read may use.
pub(crate) budget: Budget,
}
impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
pub(crate) fn new(s: &'s Stream, archive: &'b mut Archive<'a>, base: Affine, depth: usize) -> Self {
let [a, b, c, d, ..] = base.0;
Self {
s,
archive,
dpi: 72.0,
warnings: BTreeMap::new(),
active: HashSet::new(),
base,
type_base: Affine([a, b, c, d, 0.0, 0.0]),
depth,
pending: None,
master_ids: Vec::new(),
budget: Budget::FULL,
}
}
pub(crate) fn warn(&mut self, what: impl Into<String>) {
*self.warnings.entry(what.into()).or_default() += 1;
}
/// The document.
fn document(&mut self) -> Result<Document, Error> {
let s = self.s;
let root = s.root;
self.dpi = s.obj(root, b"UVCn").and_then(|u| s.f64(u, b"UPPI")).filter(|d| *d > 0.0 && *d < 100_000.0).unwrap_or(72.0);
let saved_by = s.obj(root, b"NVer").map(|v| {
let product = s.str(v, b"Prod").unwrap_or("Affinity");
match (s.int(v, b"Majr"), s.int(v, b"Minr"), s.int(v, b"Bild")) {
(Some(a), Some(b), Some(c)) => format!("{product} {a}.{b}.{c}"),
_ => product.to_string(),
}
});
let doc = s.obj(root, b"DocR").ok_or(Error::Malformed("document has no root node"))?;
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 let Some(sp) = self.spread(doc, spread)? {
spreads.push(sp);
}
}
if spreads.is_empty() {
return Err(Error::Malformed("document has no pages"));
}
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 = std::mem::take(&mut self.warnings).into_iter().map(|(w, n)| if n > 1 { format!("{w} ({n}×)") } else { w }).collect();
Ok(Document { dpi: self.dpi, spreads, masters, saved_by, warnings, cmyk_profile })
}
/// 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);
self.budget.spend(pages.len() + 1)?;
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;
let kids = s.objs(parent, b"Chld");
// Invisible masks and skipped children cost no node, so the visit is charged here.
self.budget.spend(kids.len())?;
for c in kids {
if s.is(c, b"MRst") {
// A mask layer inside a group or layer masks that container.
if s.bool(c, b"Visi") == Some(false) {
continue;
}
let local = s.floats::<6>(c, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY);
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;
}
if let Some(n) = self.node(c, world, depth + 1)? {
out.push(n);
}
}
Ok((out, mask))
}
fn node(&mut self, id: ObjId, parent: Affine, depth: usize) -> Result<Option<Node>, Error> {
let s = self.s;
if depth > MAX_TREE_DEPTH {
self.warn("layers nested deeper than 128 levels");
return Ok(None);
}
let Some(left) = self.budget.nodes.checked_sub(1) else {
return Err(Error::Limit("too many layers"));
};
self.budget.nodes = left;
// A node that contains itself (a shared object linked back into its own subtree).
if !self.active.insert(id) {
self.warn("a layer that contains itself");
return Ok(None);
}
let local = s.floats::<6>(id, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY);
let world = local.then(parent);
let class = s.class(id).unwrap_or(Tag(0));
// A layer name is short; a long one is cut, as every visit of a shared object keeps a copy.
let name: String = s.str(id, b"Desc").unwrap_or_default().chars().take(MAX_NAME_CHARS.min(self.budget.name_chars)).collect();
self.budget.name_chars -= name.chars().count();
let visible = s.bool(id, b"Visi").unwrap_or(true);
let locked = s.bool(id, b"Edtb").is_some_and(|e| !e);
let opacity = s.f64(id, b"Opac").map_or(1.0, |o| o.clamp(0.0, 1.0));
let blend = match s.enumeration(id, b"Blnd") {
None if s.bool(id, b"PasT") != Some(false) && is_container(class) => Blend::PassThrough,
None => Blend::Normal,
Some((i, v)) => Blend::from_enum(i, v).unwrap_or_else(|| {
self.warn("an unknown blend mode (imported as Normal)");
Blend::Normal
}),
};
let kind = if !world.is_finite() {
self.warn("a layer with an invalid transform");
Kind::Unsupported
} else {
self.kind(id, class, world, parent)?
};
// This node's embedded content, before its children's `node` calls can take it.
let embedded = self.pending.take();
// A compound's children are its operands, already merged into its outline.
let (mut children, child_mask) = if class == Tag::of(b"Comp") { (Vec::new(), None) } else { self.children(id, world, depth)? };
if let Some(embedded) = embedded {
children.extend(embedded);
}
// Pixel masks are read only over what they mask (they can span a whole spread at print
// resolution, a gigapixel).
let over = mask_extent(&kind, &children);
let child_mask = match child_mask {
Some((c, at)) => crate::raster::mask(self, c, at, over)?,
None => None,
};
let (mask, attached_mask) = self.attached_masks(id, world, over)?;
let pixel_mask = attached_mask.or(child_mask);
self.active.remove(&id);
let mut node = Node { class, name, visible, locked, opacity, blend, kind, mask, pixel_mask, effects: Vec::new(), children };
// Gradient overlays are laid out over the node's bounds, also while it is hidden.
let bounds = tree_bounds(&node);
node.effects = self.effects(id, world, bounds);
Ok(Some(node))
}
fn kind(&mut self, id: ObjId, class: Tag, world: Affine, parent: Affine) -> Result<Kind, Error> {
let s = self.s;
Ok(match &class.0.to_be_bytes() {
b"Scop" => Kind::Layer,
b"Grup" => Kind::Group,
b"PCrv" | b"ShpN" | b"Comp" | b"SNEN" | b"SNRR" | b"ShRN" => {
// Affinity 3.2.3's two-board public fixture marks board shapes with an `aprp`
// property object in `phrp`, while older documents use `ABEn`. Ordinary shapes
// in that fixture have neither. See the pinned corpus and validation audit.
if s.bool(id, b"ABEn") == Some(true) || s.obj(id, b"phrp").is_some_and(|p| s.is(p, b"aprp")) {
return Ok(self.artboard(id, world));
}
let Some((local, even_odd)) = crate::geometry::outline(self, id, class, world) else {
self.warn(format!("a {} that could not be read", describe(class)));
return Ok(Kind::Unsupported);
};
self.budget.spend(local.subpaths.iter().map(|sp| sp.segments.len() + 1).sum())?;
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 => {
self.warn("text whose story could not be read (master-page text, or an unknown layout)");
Kind::Unsupported
}
},
b"ImgN" | b"Rstr" => match crate::raster::node_image(self, id, world)? {
Some(img) => Kind::Image(img),
None => Kind::Unsupported,
},
b"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
}
}
}
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.
let b = s.obj(id, b"Bitm");
let size = |t: &[u8; 4]| b.and_then(|b| s.int(b, t)).map(|v| v as f64).filter(|v| *v > 0.0);
match (size(b"BmpW"), size(b"BmpH")) {
(Some(w), Some(h)) => {
let local = Path { subpaths: vec![crate::shapes::rectangle(0.0, 0.0, w, h)] };
let fills = paint::fills(self, id, parent, b"BFFl");
Kind::Shape { path: local.transformed(world), fills, strokes: Vec::new(), even_odd: false }
}
_ => {
self.warn("fill layers without an area");
Kind::Unsupported
}
}
}
_ => {
self.warn(format!("{} layers", describe(class)));
Kind::Unsupported
}
})
}
/// 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)"),
}
}
for a in s.objs(id, b"AdCh") {
if let Some(effect) = self.attached_recolor(a) {
out.push(effect);
}
}
out
}
fn attached_recolor(&mut self, a: ObjId) -> Option<Effect> {
let s = self.s;
if !s.is(a, b"RcRA") {
return None;
}
if s.bool(a, b"Visi") == Some(false) {
return None;
}
let adjp = s.obj(a, b"AdjP")?;
if !s.is(adjp, b"RecP") {
return None;
}
let h = s.f64(adjp, b"RecH").unwrap_or(0.0);
let sat = s.f64(adjp, b"RecS").unwrap_or(1.0);
let l = s.f64(adjp, b"RecL").unwrap_or(0.5);
let (r, g, b) = crate::paint::hsl(h, sat, l);
let color = crate::paint::Color::Rgb { r, g, b, a: 1.0 };
let opacity = s.f64(a, b"Opac").filter(|v| v.is_finite()).unwrap_or(1.0).clamp(0.0, 1.0);
let blend = self.effect_blend(a);
Some(Effect::ColorOverlay { color, opacity, blend })
}
/// 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, 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;
let mut pixels: Option<Image> = None;
for a in attached {
let class = s.class(a).unwrap_or(Tag(0));
if s.bool(a, b"Visi") == Some(false) {
continue;
}
if class == Tag::of(b"PCrv") || class == Tag::of(b"ShpN") || class == Tag::of(b"Comp") {
let local = s.floats::<6>(a, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY).then(world);
if let Some((p, _)) = crate::geometry::outline(self, a, class, local) {
mask.get_or_insert_with(Path::default).subpaths.extend(p.transformed(local).subpaths);
}
} else if class == Tag::of(b"MRst") {
let local = s.floats::<6>(a, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY).then(world);
match crate::raster::mask(self, a, local, 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 => {}
}
} else if class == Tag::of(b"RcRA") {
// Handled in `effects` as a colour overlay.
} else {
self.warn(format!("{} layers attached to other layers (imported without them)", describe(class)));
}
}
Ok((mask, pixels))
}
/// The content of an Affinity document embedded in this one (`EmbN` whose `EmbC` entry holds
/// `EmDc`, four more bytes and a complete Affinity document): its first spread's nodes,
/// placed so the centre of its page, first artboard or content is the node's origin.
fn embedded_document(&mut self, id: ObjId, world: Affine) -> Option<Vec<Node>> {
let s = self.s;
let entry = s.obj(id, b"Bitm").and_then(|b| s.obj(b, b"EmCn")).and_then(|c| s.entry(c, b"EmbC"))?.to_string();
if entry.is_empty() {
return None;
}
if self.depth >= MAX_EMBED_DEPTH {
self.warn("embedded documents nested more than four deep");
return None;
}
let data = self.archive.read(&entry).ok()?;
let inner = data.strip_prefix(b"EmDc").and_then(|d| d.get(4..))?;
if !crate::is_affinity(inner) {
return None;
}
// A first reading finds its page and resolution, the second places it.
// Both readings are charged to the budget and the archive's limits, also when they fail:
// each does the work.
let mut budget = self.budget;
let probe = read_at(inner, self.archive.remaining(), &mut budget, Affine::IDENTITY, self.depth + 1);
self.budget = budget;
if self.archive.charge(probe.extracted).is_err() {
self.warn("embedded documents beyond the size limit (left out)");
return None;
}
let probe = match probe.document {
Ok(d) => d,
Err(e) => {
self.warn(format!("embedded documents that could not be read ({e})"));
return None;
}
};
let spread = probe.spreads.first()?;
// `PBBx` 2 places the content's own bounds (as with a cached picture); otherwise its page.
let content = s.obj(id, b"Bitm").and_then(|b| s.enumeration(b, b"PBBx")).is_some_and(|(k, _)| k == 2);
let first_board = spread.nodes.iter().find_map(|n| if let Kind::Artboard { rect, .. } = &n.kind { Some(*rect) } else { None });
let page = if content { None } else { spread.pages.first().copied().or(first_board) };
let page = page.or_else(|| nodes_bounds(&spread.nodes)).unwrap_or(spread.bounds);
// Its pixels aren't needed any more: never hold both readings' at once.
drop(probe);
// Its document pixels are the node's units, whatever either document's resolution.
let k = 1.0;
let (cx, cy) = ((page.x0 + page.x1) / 2.0, (page.y0 + page.y1) / 2.0);
let base = Affine([k, 0.0, 0.0, k, -cx * k, -cy * k]).then(world);
if !base.is_finite() {
self.warn("embedded documents with an invalid placement");
return None;
}
let mut budget = self.budget;
let placed = read_at(inner, self.archive.remaining(), &mut budget, base, self.depth + 1);
self.budget = budget;
if self.archive.charge(placed.extracted).is_err() {
self.warn("embedded documents beyond the size limit (left out)");
return None;
}
let doc = placed.document.ok()?;
for w in doc.warnings {
self.warn(format!("{w} [in embedded documents]"));
}
if doc.spreads.len() > 1 {
self.warn("embedded documents with several pages (the first is used)");
}
let mut nodes = doc.spreads.into_iter().next()?.nodes;
// Its master page instances count its own masters, which aren't kept: in this document
// they would show other masters.
fn strip_masters(nodes: &mut [Node], depth: usize) -> bool {
let mut found = false;
for n in nodes {
if matches!(n.kind, Kind::MasterInstance { .. }) {
n.kind = Kind::Unsupported;
found = true;
}
if depth < MAX_TREE_DEPTH {
found |= strip_masters(&mut n.children, depth + 1);
}
}
found
}
if strip_masters(&mut nodes, 0) {
self.warn("master pages of embedded documents (left out)");
}
Some(nodes)
}
fn artboard(&mut self, id: ObjId, world: Affine) -> Kind {
let s = self.s;
let Some(b) = s.floats::<4>(id, b"ShpB").map(rect).filter(|b| b.x1 > b.x0 && b.y1 > b.y0) else {
self.warn("an artboard without valid bounds (its children are kept without clipping)");
return Kind::Unsupported;
};
let corners =
[Point { x: b.x0, y: b.y0 }, Point { x: b.x1, y: b.y0 }, Point { x: b.x1, y: b.y1 }, Point { x: b.x0, y: b.y1 }].map(|p| world.apply(p));
let [a, b2, c, d, ..] = world.0;
if b2.abs() > 1e-9 || c.abs() > 1e-9 || a <= 0.0 || d <= 0.0 {
self.warn("a rotated or flipped artboard (export bounds are its bounding box; artwork keeps its outline)");
}
let xs = corners.map(|p| p.x);
let ys = corners.map(|p| p.y);
let rect = Rect {
x0: xs.iter().copied().fold(f64::INFINITY, f64::min),
y0: ys.iter().copied().fold(f64::INFINITY, f64::min),
x1: xs.iter().copied().fold(f64::NEG_INFINITY, f64::max),
y1: ys.iter().copied().fold(f64::NEG_INFINITY, f64::max),
};
if ![rect.x0, rect.y0, rect.x1, rect.y1].iter().all(|v| v.is_finite()) || rect.x1 <= rect.x0 || rect.y1 <= rect.y0 {
self.warn("an artboard with an invalid transformed size (its children are kept without clipping)");
return Kind::Unsupported;
}
let class = s.class(id).unwrap_or(Tag::of(b"ShpN"));
let (path, even_odd) = crate::geometry::outline(self, id, class, world).unwrap_or_else(|| {
self.warn("an artboard whose outline could not be read (clipped to its rectangle)");
(Path { subpaths: vec![crate::shapes::rectangle(b.x0, b.y0, b.x1, b.y1)] }, false)
});
let (background, strokes) = paint::node_paint(self, id, world);
Kind::Artboard { rect, path: path.transformed(world), even_odd, background, strokes }
}
}
#[cfg(test)]
mod budget_tests {
use super::*;
#[test]
fn shared_work_is_bounded() {
let mut b = Budget::FULL;
assert!(b.spend(MAX_DOCUMENT_WORK).is_ok());
assert_eq!(b.spend(1), Err(Error::Limit("too much shared geometry or page data")));
}
}
-675
View File
@@ -1,675 +0,0 @@
//! Colour and gradient overlays. VectorCraft has no such effect, so an overlay is baked into what
//! its node paints: fill, stroke and text colours, and pixels. The overlay lies over the node's
//! own paint in its blend mode at its opacity, inside the node's alpha (which it keeps).
//!
//! Blend modes follow the W3C Compositing and Blending formulas; the modes PDF lacks (Vivid
//! Light, Linear Light, Pin Light, Hard Mix, Add, Subtract, Average, Negation, Reflect, Glow,
//! Darker/Lighter Color) use their usual definitions.
use std::borrow::Cow;
use crate::model::{Affine, Blend, Effect, Image, Kind, Node, Pixels, Point};
use crate::paint::{Color, Gradient, GradientKind, Paint, Stop, Stroke};
/// Deepest subtree an overlay is baked into (the reader bounds trees well below this).
const MAX_DEPTH: usize = 256;
/// Most pixels an overlay is baked into per image.
const MAX_PIXELS: usize = 64 << 20;
/// Work the overlays of one import may take together: each overlay counts the nodes and pixels
/// of the subtree it is baked into (which is copied once more for them all). Nested overlays
/// and shared content would otherwise multiply it; past it, overlays are left out and said.
pub const MAX_WORK: u64 = 1 << 29;
#[derive(Clone, Copy)]
enum Source<'a> {
Solid(Color),
Gradient(&'a Gradient),
}
#[derive(Clone, Copy)]
struct Overlay<'a> {
source: Source<'a>,
opacity: f64,
blend: Blend,
}
/// `node` with its colour and gradient overlays baked in, and those of everything in it
/// (borrowed unchanged when `node` has none, or when what is left of `work`, see [`MAX_WORK`],
/// can't pay for copying it). `warn` hears what could only be approximated or was left out.
pub fn apply<'a>(node: &'a Node, work: &mut u64, warn: &mut dyn FnMut(&str)) -> Cow<'a, Node> {
if overlays(&node.effects).is_empty() {
return Cow::Borrowed(node);
}
let Some(left) = work.checked_sub(subtree_cost(node, *work, 0)) else {
warn("overlay effects beyond the work one import may take (left out)");
return Cow::Borrowed(node);
};
*work = left;
let mut out = node.clone();
bake_tree(&mut out, work, 0, warn);
Cow::Owned(out)
}
/// The visible colour and gradient overlays among `effects`.
fn overlays(effects: &[Effect]) -> Vec<Overlay<'_>> {
effects
.iter()
.filter_map(|e| match e {
Effect::ColorOverlay { color, opacity, blend } => Some(Overlay { source: Source::Solid(*color), opacity: *opacity, blend: *blend }),
Effect::GradientOverlay { gradient, opacity, blend } => {
Some(Overlay { source: Source::Gradient(gradient), opacity: *opacity, blend: *blend })
}
_ => None,
})
.filter(|o| o.opacity > 0.0)
.collect()
}
fn is_overlay(e: &Effect) -> bool {
matches!(e, Effect::ColorOverlay { .. } | Effect::GradientOverlay { .. })
}
/// Bakes the overlays of `node` and its subtree into their paint, innermost first: an overlay
/// lies over what its node shows, its children's overlays included. Each overlay pays for the
/// subtree it is baked into out of `work`; past it, it is left out (and said).
fn bake_tree(node: &mut Node, work: &mut u64, depth: usize, warn: &mut dyn FnMut(&str)) {
if depth > MAX_DEPTH {
return;
}
for c in &mut node.children {
bake_tree(c, work, depth + 1, warn);
}
if !node.effects.iter().any(is_overlay) {
return;
}
let (effects, rest): (Vec<Effect>, Vec<Effect>) = std::mem::take(&mut node.effects).into_iter().partition(is_overlay);
node.effects = rest;
let overlays = overlays(&effects);
if overlays.is_empty() {
return;
}
let count = u64::try_from(overlays.len()).unwrap_or(u64::MAX);
let cost = subtree_cost(node, *work / count, 0).saturating_mul(count);
let Some(left) = work.checked_sub(cost) else {
warn("overlay effects beyond the work one import may take (left out)");
return;
};
*work = left;
for o in &overlays {
if matches!(o.blend, Blend::Erase | Blend::PassThrough) {
warn("overlay effects in a blend mode VectorCraft doesn't have (imported as Normal)");
}
bake_node(node, o, 0, warn);
}
}
/// Nodes and pixels in `node`'s subtree, counted only until they pass `limit`.
fn subtree_cost(node: &Node, limit: u64, depth: usize) -> u64 {
if depth > MAX_DEPTH {
return 0;
}
let mut cost: u64 = match &node.kind {
Kind::Image(img) => (u64::from(img.width) * u64::from(img.height)).saturating_add(1),
Kind::Text(t) => u64::try_from(t.runs.len()).unwrap_or(u64::MAX).saturating_add(1),
Kind::Table(t) => u64::try_from(t.cells.len()).unwrap_or(u64::MAX).saturating_add(1),
_ => 1,
};
for c in &node.children {
if cost > limit {
break;
}
cost = cost.saturating_add(subtree_cost(c, limit - cost, depth + 1));
}
cost
}
fn bake_node(node: &mut Node, o: &Overlay, depth: usize, warn: &mut dyn FnMut(&str)) {
if depth > MAX_DEPTH {
return;
}
match &mut node.kind {
Kind::Shape { fills, strokes, .. } => {
for f in fills.iter_mut() {
*f = paint(f, o, warn);
}
strokes.iter_mut().for_each(|s| stroke(s, o, warn));
}
Kind::Artboard { background, strokes, .. } => {
for f in background.iter_mut() {
*f = paint(f, o, warn);
}
strokes.iter_mut().for_each(|s| stroke(s, o, warn));
}
Kind::Text(t) => {
for r in &mut t.runs {
r.fill = paint(&r.fill, o, warn);
if let Some(s) = &mut r.stroke {
stroke(s, o, warn);
}
}
}
Kind::Table(t) => {
for c in &mut t.cells {
for r in &mut c.runs {
r.fill = paint(&r.fill, o, warn);
if let Some(s) = &mut r.stroke {
stroke(s, o, warn);
}
}
}
for s in t.vertical.iter_mut().chain(t.horizontal.iter_mut()).flatten() {
stroke(s, o, warn);
}
}
Kind::Image(img) => {
if !pixels(img, o) {
warn("overlay effects on images too large to recolour (left out)");
}
}
Kind::Layer | Kind::Group | Kind::Unsupported | Kind::MasterInstance { .. } => {}
}
for e in &mut node.effects {
match e {
Effect::Outline { color, .. } => *color = over(*color, o.source.at_solid(), o),
Effect::DropShadow { .. } | Effect::InnerShadow { .. } | Effect::OuterGlow { .. } | Effect::Bevel { .. } => {}
Effect::ColorOverlay { .. } | Effect::GradientOverlay { .. } => {}
}
}
for c in &mut node.children {
bake_node(c, o, depth + 1, warn);
}
}
fn stroke(s: &mut Stroke, o: &Overlay, warn: &mut dyn FnMut(&str)) {
s.paint = paint(&s.paint, o, warn);
}
/// A paint with the overlay over it.
fn paint(p: &Paint, o: &Overlay, warn: &mut dyn FnMut(&str)) -> Paint {
match (p, o.source) {
(Paint::None, _) => Paint::None,
(Paint::Solid(c), Source::Solid(s)) => Paint::Solid(over(*c, s, o)),
(Paint::Gradient(g), Source::Solid(s)) => {
Paint::Gradient(Gradient { stops: g.stops.iter().map(|st| Stop { color: over(st.color, s, o), ..*st }).collect(), ..g.clone() })
}
// Over one colour, the result is the overlay's gradient with each stop laid over it.
(Paint::Solid(c), Source::Gradient(g)) => {
Paint::Gradient(Gradient { stops: g.stops.iter().map(|st| Stop { color: over(*c, st.color, o), ..*st }).collect(), ..g.clone() })
}
(Paint::Gradient(base), Source::Gradient(g)) => {
if !(o.blend == Blend::Normal && o.opacity >= 1.0) {
warn("gradient overlays on gradients (approximated)");
}
// Exact when the overlay covers it; otherwise over the base gradient's average colour.
let avg = average(base);
Paint::Gradient(Gradient { stops: g.stops.iter().map(|st| Stop { color: over(avg, st.color, o), ..*st }).collect(), ..g.clone() })
}
}
}
impl Source<'_> {
/// One colour for the source (a gradient's average) where only one fits.
fn at_solid(&self) -> Color {
match self {
Source::Solid(c) => *c,
Source::Gradient(g) => average(g),
}
}
}
fn average(g: &Gradient) -> Color {
let n = g.stops.len().max(1) as f64;
let (mut r, mut gr, mut b, mut a) = (0.0, 0.0, 0.0, 0.0);
for s in &g.stops {
let [sr, sg, sb] = rgb(s.color);
r += sr;
gr += sg;
b += sb;
a += s.color.alpha();
}
Color::Rgb { r: r / n, g: gr / n, b: b / n, a: a / n }
}
/// `source` laid over `base` in the overlay's mode and opacity; `base`'s alpha is kept.
fn over(base: Color, source: Color, o: &Overlay) -> Color {
let k = (o.opacity * source.alpha()).clamp(0.0, 1.0);
let alpha = base.alpha();
// Normal mixes within the colour model when both share it, so CMYK stays CMYK.
if matches!(o.blend, Blend::Normal | Blend::PassThrough | Blend::Erase) {
let mix = |a: f64, b: f64| a + (b - a) * k;
match (base, source) {
(Color::Cmyk { c, m, y, k: kk, .. }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2, .. }) => {
return Color::Cmyk { c: mix(c, c2), m: mix(m, m2), y: mix(y, y2), k: mix(kk, k2), a: alpha };
}
(Color::Gray { v, .. }, Color::Gray { v: v2, .. }) => return Color::Gray { v: mix(v, v2), a: alpha },
(_, s) if k >= 1.0 => return with_alpha(s, alpha),
_ => {}
}
}
let b = rgb(base);
let s = rgb(source);
let blended = blend(o.blend, b, s);
let [r, g, bl] = [0, 1, 2].map(|i| b[i] + (blended[i] - b[i]) * k);
Color::Rgb { r, g, b: bl, a: alpha }
}
fn with_alpha(c: Color, a: f64) -> Color {
match c {
Color::Rgb { r, g, b, .. } => Color::Rgb { r, g, b, a },
Color::Cmyk { c, m, y, k, .. } => Color::Cmyk { c, m, y, k, a },
Color::Gray { v, .. } => Color::Gray { v, a },
Color::Lab { l, a: la, b, .. } => Color::Lab { l, a: la, b, alpha: a },
}
}
/// sRGB components (0..1) of a colour. CMYK uses the profile-free formula (the engine shows the
/// node's other CMYK colours through the document's profile), Lab its D50 sRGB colour.
fn rgb(c: Color) -> [f64; 3] {
let f = |v: f64| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 };
match c {
Color::Rgb { r, g, b, .. } => [f(r), f(g), f(b)],
Color::Gray { v, .. } => [f(v); 3],
Color::Cmyk { c, m, y, k, .. } => {
let w = 1.0 - f(k);
[(1.0 - f(c)) * w, (1.0 - f(m)) * w, (1.0 - f(y)) * w]
}
Color::Lab { l, a, b, .. } => lab_to_srgb(l, a, b).map(f),
}
}
/// CIE L*a*b* (D50) to sRGB, through XYZ and the Bradford adaptation to D65.
fn lab_to_srgb(l: f64, a: f64, b: f64) -> [f64; 3] {
let finite = |v: f64| if v.is_finite() { v } else { 0.0 };
let (l, a, b) = (finite(l), finite(a), finite(b));
let fy = (l + 16.0) / 116.0;
let (fx, fz) = (fy + a / 500.0, fy - b / 200.0);
let inv = |t: f64| if t > 6.0 / 29.0 { t * t * t } else { 3.0 * (6.0f64 / 29.0).powi(2) * (t - 4.0 / 29.0) };
// D50 white.
let (x, y, z) = (0.964_22 * inv(fx), inv(fy), 0.825_21 * inv(fz));
// XYZ (D50) to linear sRGB, Bradford-adapted.
let r = 3.133_856 * x - 1.616_867 * y - 0.490_615 * z;
let g = -0.978_768 * x + 1.916_142 * y + 0.033_454 * z;
let bl = 0.071_945 * x - 0.228_991 * y + 1.405_243 * z;
let gamma = |v: f64| if v <= 0.003_130_8 { 12.92 * v } else { 1.055 * v.max(0.0).powf(1.0 / 2.4) - 0.055 };
[gamma(r), gamma(g), gamma(bl)]
}
/// Bake the overlay into an image's pixels. False if they couldn't be read or are too many.
fn pixels(img: &mut Image, o: &Overlay) -> bool {
let Some(n) = (img.width as usize).checked_mul(img.height as usize).filter(|n| *n <= MAX_PIXELS) else { return false };
let Some(len) = n.checked_mul(4) else { return false };
let mut px = match &img.pixels {
Pixels::Rgba8(p) => match p.get(..len) {
Some(p) => p.to_vec(),
None => return false,
},
Pixels::Encoded(bytes) => match decode(bytes, img.width, img.height) {
Some(p) => p,
None => return false,
},
// An overlay is blended in RGB: CMYK pixels take the profile-free formula here.
Pixels::Cmyka8(p) => match p.get(..n.saturating_mul(5)) {
Some(p) if p.len() == n * 5 => p
.as_chunks::<5>()
.0
.iter()
.flat_map(|[c, m, y, k, a]| {
let w = 255 - u16::from(*k);
let ch = |v: u8| ((u16::from(255 - v) * w + 127) / 255) as u8;
[ch(*c), ch(*m), ch(*y), *a]
})
.collect(),
_ => return false,
},
};
let width = img.width.max(1) as usize;
// A solid source is converted once; a gradient is sampled at each pixel's centre.
let solid = match o.source {
Source::Solid(c) => Some((rgb(c), c.alpha())),
Source::Gradient(_) => None,
};
let to_unit = match o.source {
Source::Gradient(g) => match invert(g.transform) {
Some(m) => Some((img.transform.then(m), g, stop_reach(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, reach))) => {
let q = m.apply(Point { x: (i % width) as f64 + 0.5, y: (i / width) as f64 + 0.5 });
let c = sample(g, reach, q);
(rgb(c), c.alpha())
}
(None, None) => continue,
};
let k = (o.opacity * sa).clamp(0.0, 1.0);
let b = [p[0], p[1], p[2]].map(|v| f64::from(v) / 255.0);
let m = blend(o.blend, b, s);
for c in 0..3 {
p[c] = ((b[c] + (m[c] - b[c]) * k).clamp(0.0, 1.0) * 255.0).round() as u8;
}
}
img.pixels = Pixels::Rgba8(px);
true
}
/// An encoded original's RGBA pixels, decoded only if it is `width` × `height` as its layer says.
fn decode(bytes: &[u8], width: u32, height: u32) -> Option<Vec<u8>> {
let probe = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?;
if probe.into_dimensions().ok()? != (width, height) {
return None;
}
let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?;
let mut limits = image::Limits::default();
limits.max_image_width = Some(width);
limits.max_image_height = Some(height);
limits.max_alloc = u64::try_from(MAX_PIXELS).ok().map(|n| n * 8);
reader.limits(limits);
Some(reader.decode().ok()?.into_rgba8().into_raw())
}
/// For each pair of neighbouring stops (`k`, `k + 1`), the largest offset of stops `1..=k + 1`:
/// the first pair whose end offset a `t` doesn't pass is the first whose reach it doesn't pass, and
/// reaches never decrease, so [`sample`] finds it by binary search, sorted stops or not.
fn stop_reach(g: &Gradient) -> Vec<f64> {
g.stops
.iter()
.skip(1)
.scan(f64::NEG_INFINITY, |m, s| {
*m = m.max(s.offset);
Some(*m)
})
.collect()
}
/// The gradient's colour at unit-space point `q` (`reach`: [`stop_reach`] of `g`).
fn sample(g: &Gradient, reach: &[f64], 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;
}
let k = reach.partition_point(|r| *r < t);
if let (Some(a), Some(b)) = (g.stops.get(k), g.stops.get(k + 1)) {
let span = b.offset - a.offset;
let u = if span > 1e-9 { (t - a.offset) / span } else { 1.0 };
// Affinity's midpoint bias (see `Stop::half_point`).
let k = 1.0 + 8.0 * (a.bias - 0.5).abs();
let u = if a.bias >= 0.5 { u.powf(k) } else { 1.0 - (1.0 - u).powf(k) };
let (x, y) = (rgb(a.color), rgb(b.color));
let [r, g, bl] = [0, 1, 2].map(|i| x[i] + (y[i] - x[i]) * u);
return Color::Rgb { r, g, b: bl, a: a.color.alpha() + (b.color.alpha() - a.color.alpha()) * u };
}
last.color
}
fn invert(m: Affine) -> Option<Affine> {
let [a, b, c, d, e, f] = m.0;
let det = a * d - b * c;
if !(det.is_finite() && det.abs() > 1e-12) {
return None;
}
let (ia, ib, ic, id) = (d / det, -b / det, -c / det, a / det);
Some(Affine([ia, ib, ic, id, -(ia * e + ic * f), -(ib * e + id * f)]))
}
/// The blend of backdrop `b` and source `s` (sRGB, 0..1).
fn blend(mode: Blend, b: [f64; 3], s: [f64; 3]) -> [f64; 3] {
use Blend::*;
let sep = |f: fn(f64, f64) -> f64| [0, 1, 2].map(|i| f(b[i], s[i]).clamp(0.0, 1.0));
match mode {
Normal | PassThrough | Erase => s,
Darken => sep(f64::min),
Lighten => sep(f64::max),
Multiply => sep(|b, s| b * s),
Screen => sep(screen),
ColorBurn => sep(burn),
ColorDodge => sep(dodge),
Blend::Overlay => sep(|b, s| hard_light(s, b)),
HardLight => sep(hard_light),
SoftLight => sep(soft_light),
Difference => sep(|b, s| (b - s).abs()),
Exclusion => sep(|b, s| b + s - 2.0 * b * s),
Add => sep(|b, s| b + s),
Subtract => sep(|b, s| b - s),
VividLight => sep(|b, s| if s <= 0.5 { burn(b, 2.0 * s) } else { dodge(b, 2.0 * s - 1.0) }),
LinearLight => sep(|b, s| b + 2.0 * s - 1.0),
PinLight => sep(|b, s| if s <= 0.5 { b.min(2.0 * s) } else { b.max(2.0 * s - 1.0) }),
HardMix => sep(|b, s| if b + s >= 1.0 { 1.0 } else { 0.0 }),
Average => sep(|b, s| (b + s) / 2.0),
Negation => sep(|b, s| 1.0 - (1.0 - b - s).abs()),
Reflect => sep(reflect),
Glow => sep(|b, s| reflect(s, b)),
DarkerColor => {
if lum(s) < lum(b) {
s
} else {
b
}
}
LighterColor => {
if lum(s) > lum(b) {
s
} else {
b
}
}
Hue => set_lum(set_sat(s, sat(b)), lum(b)),
Saturation => set_lum(set_sat(b, sat(s)), lum(b)),
Blend::Color => set_lum(s, lum(b)),
Luminosity => set_lum(b, lum(s)),
}
}
fn screen(b: f64, s: f64) -> f64 {
b + s - b * s
}
fn burn(b: f64, s: f64) -> f64 {
if b >= 1.0 {
1.0
} else if s <= 0.0 {
0.0
} else {
1.0 - ((1.0 - b) / s).min(1.0)
}
}
fn dodge(b: f64, s: f64) -> f64 {
if b <= 0.0 {
0.0
} else if s >= 1.0 {
1.0
} else {
(b / (1.0 - s)).min(1.0)
}
}
fn hard_light(b: f64, s: f64) -> f64 {
if s <= 0.5 { b * 2.0 * s } else { screen(b, 2.0 * s - 1.0) }
}
fn soft_light(b: f64, s: f64) -> f64 {
if s <= 0.5 {
b - (1.0 - 2.0 * s) * b * (1.0 - b)
} else {
let d = if b <= 0.25 { ((16.0 * b - 12.0) * b + 4.0) * b } else { b.sqrt() };
b + (2.0 * s - 1.0) * (d - b)
}
}
fn reflect(b: f64, s: f64) -> f64 {
if s >= 1.0 { 1.0 } else { (b * b / (1.0 - s)).min(1.0) }
}
fn lum(c: [f64; 3]) -> f64 {
0.3 * c[0] + 0.59 * c[1] + 0.11 * c[2]
}
fn clip_color(c: [f64; 3]) -> [f64; 3] {
let l = lum(c);
let n = c[0].min(c[1]).min(c[2]);
let x = c[0].max(c[1]).max(c[2]);
c.map(|v| {
let mut v = v;
if n < 0.0 && l - n > 1e-12 {
v = l + (v - l) * l / (l - n);
}
if x > 1.0 && x - l > 1e-12 {
v = l + (v - l) * (1.0 - l) / (x - l);
}
v.clamp(0.0, 1.0)
})
}
fn set_lum(c: [f64; 3], l: f64) -> [f64; 3] {
let d = l - lum(c);
clip_color(c.map(|v| v + d))
}
fn sat(c: [f64; 3]) -> f64 {
c[0].max(c[1]).max(c[2]) - c[0].min(c[1]).min(c[2])
}
fn set_sat(c: [f64; 3], s: f64) -> [f64; 3] {
let max = c[0].max(c[1]).max(c[2]);
let min = c[0].min(c[1]).min(c[2]);
if max - min <= 1e-12 {
return [0.0; 3];
}
c.map(|v| (v - min) * s / (max - min))
}
#[cfg(test)]
mod tests {
use super::*;
fn solid(c: Color, opacity: f64, blend: Blend) -> Node {
Node {
class: crate::stream::Tag::of(b"ShpN"),
name: String::new(),
visible: true,
locked: false,
opacity: 1.0,
blend: Blend::Normal,
kind: Kind::Shape {
path: Default::default(),
fills: vec![Paint::Solid(Color::Rgb { r: 1.0, g: 0.5, b: 0.0, a: 1.0 })],
strokes: vec![],
even_odd: false,
},
mask: None,
pixel_mask: None,
effects: vec![Effect::ColorOverlay { color: c, opacity, blend }],
children: vec![],
}
}
fn fill(n: &Node) -> Color {
match &n.kind {
Kind::Shape { fills, .. } => match fills.first() {
Some(Paint::Solid(c)) => *c,
other => panic!("{other:?}"),
},
other => panic!("{other:?}"),
}
}
#[test]
fn a_full_colour_overlay_replaces_the_fill_and_keeps_its_alpha() {
let white = Color::Rgb { r: 1.0, g: 1.0, b: 1.0, a: 1.0 };
let n = apply(&solid(white, 1.0, Blend::Normal), &mut { MAX_WORK }, &mut |_| {}).into_owned();
assert_eq!(fill(&n), white);
assert!(n.effects.is_empty(), "the overlay is baked, not kept");
// Half way, mixed in RGB.
let n = apply(&solid(white, 0.5, Blend::Normal), &mut { MAX_WORK }, &mut |_| {}).into_owned();
assert_eq!(fill(&n), Color::Rgb { r: 1.0, g: 0.75, b: 0.5, a: 1.0 });
}
#[test]
fn blend_modes_follow_their_formulas() {
let b = [0.25, 0.5, 1.0];
assert_eq!(blend(Blend::Multiply, b, [0.5; 3]), [0.125, 0.25, 0.5]);
assert_eq!(blend(Blend::Screen, b, [0.5; 3]), [0.625, 0.75, 1.0]);
assert_eq!(blend(Blend::VividLight, [0.5; 3], [0.75; 3]), [1.0; 3]);
assert_eq!(blend(Blend::HardMix, [0.5; 3], [0.6; 3]), [1.0; 3]);
// Color keeps the backdrop's luminosity with the source's hue and saturation.
let c = blend(Blend::Color, [0.5; 3], [1.0, 0.0, 0.0]);
assert!((lum(c) - 0.5).abs() < 1e-9 && c[0] > c[1] && (c[1] - c[2]).abs() < 1e-9, "{c:?}");
}
#[test]
fn overlays_recolour_pixels_inside_their_alpha() {
let mut n = solid(Color::Rgb { r: 0.0, g: 0.0, b: 1.0, a: 1.0 }, 1.0, Blend::Normal);
n.kind = Kind::Image(Image { width: 2, height: 1, pixels: Pixels::Rgba8(vec![255, 0, 0, 255, 255, 0, 0, 0]), transform: Affine::IDENTITY });
let out = apply(&n, &mut { MAX_WORK }, &mut |_| {}).into_owned();
let Kind::Image(img) = &out.kind else { panic!() };
assert_eq!(img.pixels, Pixels::Rgba8(vec![0, 0, 255, 255, 255, 0, 0, 0]));
}
#[test]
fn a_gradient_overlay_over_one_colour_becomes_that_gradient() {
let g = Gradient {
kind: GradientKind::Linear,
stops: vec![
Stop { offset: 0.0, color: Color::Rgb { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }, bias: 0.5 },
Stop { offset: 1.0, color: Color::Rgb { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }, bias: 0.5 },
],
transform: Affine([10.0, 0.0, 0.0, 10.0, 0.0, 0.0]),
};
let mut n = solid(Color::Rgb { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }, 1.0, Blend::Normal);
n.effects = vec![Effect::GradientOverlay { gradient: g.clone(), opacity: 1.0, blend: Blend::Normal }];
let out = apply(&n, &mut { MAX_WORK }, &mut |_| {}).into_owned();
let Kind::Shape { fills, .. } = &out.kind else { panic!() };
assert_eq!(fills.first(), Some(&Paint::Gradient(g)));
}
#[test]
fn a_group_overlay_lies_over_its_childrens_overlays() {
let red = Color::Rgb { r: 1.0, g: 0.0, b: 0.0, a: 1.0 };
let blue = Color::Rgb { r: 0.0, g: 0.0, b: 1.0, a: 1.0 };
let mut group = solid(red, 0.5, Blend::Normal);
group.kind = Kind::Group;
group.children = vec![solid(blue, 1.0, Blend::Normal)];
// The copy (2 nodes), the child's overlay (1) and the group's (2).
let mut work = 5;
let out = apply(&group, &mut work, &mut |_| {}).into_owned();
assert_eq!(work, 0);
let child = out.children.first().unwrap();
// Blue first, then half of the group's red over it.
assert_eq!(fill(child), Color::Rgb { r: 0.5, g: 0.0, b: 0.5, a: 1.0 });
assert!(out.effects.is_empty() && child.effects.is_empty(), "both overlays are baked");
// With no work left for the group's overlay, the child's still applies.
let mut warnings = Vec::new();
let out = apply(&group, &mut { 4 }, &mut |w| warnings.push(w.to_string())).into_owned();
assert_eq!(fill(out.children.first().unwrap()), blue);
assert!(warnings.iter().any(|w| w.contains("beyond the work")), "{warnings:?}");
}
#[test]
fn overlays_stop_at_the_work_one_import_may_take() {
let mut n = solid(Color::Rgb { r: 0.0, g: 0.0, b: 1.0, a: 1.0 }, 1.0, Blend::Normal);
n.kind = Kind::Image(Image { width: 4, height: 4, pixels: Pixels::Rgba8(vec![255; 64]), transform: Affine::IDENTITY });
// 16 pixels and the node, for the copy and for the overlay: 34.
let mut work = 40;
let mut warnings = Vec::new();
assert!(matches!(apply(&n, &mut work, &mut |w| warnings.push(w.to_string())), Cow::Owned(_)));
assert_eq!((work, warnings.len()), (6, 0));
assert!(matches!(apply(&n, &mut work, &mut |w| warnings.push(w.to_string())), Cow::Borrowed(_)));
assert_eq!(work, 6);
assert!(warnings.iter().any(|w| w.contains("beyond the work")), "{warnings:?}");
}
}
-403
View File
@@ -1,403 +0,0 @@
//! Fills, strokes and colours. Two schemas exist: Affinity 1 (`BFil` fill, `PFil` stroke paint,
//! `LSty` stroke style) and Affinity 2/3 (`BFFl`, `LIFl` and `LILn` lists with a current index).
use crate::model::{Affine, Reader};
use crate::stream::{ObjId, Value};
/// A colour as stored: RGB and grey values are encoded in the document's RGB space (sRGB unless
/// the document embeds another profile), CMYK values go through its CMYK profile. Alpha is straight.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Color {
Rgb {
r: f64,
g: f64,
b: f64,
a: f64,
},
Cmyk {
c: f64,
m: f64,
y: f64,
k: f64,
a: f64,
},
Gray {
v: f64,
a: f64,
},
/// CIE L*a*b* (D50).
Lab {
l: f64,
a: f64,
b: f64,
alpha: f64,
},
}
impl Color {
pub fn alpha(&self) -> f64 {
match self {
Color::Rgb { a, .. } | Color::Cmyk { a, .. } | Color::Gray { a, .. } => *a,
Color::Lab { alpha, .. } => *alpha,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GradientKind {
Linear,
/// Elliptical and radial: distance from the centre in unit space.
Radial,
/// Angle around the centre.
Conical,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Stop {
pub offset: f64,
pub color: Color,
/// Affinity's midpoint bias for the segment from this stop to the next (0.5 = even blend).
pub bias: f64,
}
impl Stop {
/// Where, between this stop (0) and the next (1), the blend reaches half way: the bias curve
/// fitted to Affinity's renders is `t^k` (bias ≥ 0.5) or `1-(1-t)^k`, with `k = 1 + 8·|bias-0.5|`.
pub fn half_point(&self) -> f64 {
let k = 1.0 + 8.0 * (self.bias - 0.5).abs();
let h = 0.5f64.powf(1.0 / k);
if self.bias >= 0.5 { h } else { 1.0 - h }
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Gradient {
pub kind: GradientKind,
pub stops: Vec<Stop>,
/// Unit space to document pixels: linear runs from unit (0,0) to (1,0); radial is the unit
/// circle; conical starts along unit +x and turns towards unit +y.
pub transform: Affine,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub enum Paint {
#[default]
None,
Solid(Color),
Gradient(Gradient),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Cap {
Butt,
Round,
Square,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Join {
Miter,
Round,
Bevel,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Align {
Center,
Inside,
Outside,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Stroke {
pub paint: Paint,
/// Width in document pixels.
pub width: f64,
pub cap: Cap,
pub join: Join,
pub miter_limit: f64,
pub align: Align,
/// Dash and gap lengths in document pixels, and the phase.
pub dash: Option<(Vec<f64>, f64)>,
/// Painted below the fill instead of above it.
pub behind: bool,
/// The pressure curve: (position 0..1 along the path, width as a fraction of `width`) in
/// order along the path, `None` for a stroke of the same width all along.
pub profile: Option<Vec<(f64, f64)>>,
}
/// Gradient stops per gradient.
const MAX_STOPS: usize = 1024;
/// Dash and gap lengths per dash pattern (a pattern has a few; strokes and text runs copy theirs).
const MAX_DASHES: usize = 64;
fn floats(v: &Value) -> Option<Vec<f64>> {
match v {
Value::Floats(f) => Some(f.clone()),
Value::Bytes(b) if b.len() % 4 == 0 => Some(b.as_chunks::<4>().0.iter().map(|c| f64::from(f32::from_le_bytes(*c))).collect()),
_ => None,
}
}
/// A colour object (`RGBA`, `HSLA`, `CMYK`, `GRAY`, `LABA`, `Pant`).
pub(crate) fn color(r: &mut Reader, id: ObjId) -> Option<Color> {
color_within(r, id, 0)
}
/// The most spot colours that stand for one another followed.
const MAX_SPOT_CHAIN: u32 = 8;
/// [`color`], `depth` spot colours deep: one that stands for itself (or a ring of them) ends here.
fn color_within(r: &mut Reader, id: ObjId, depth: u32) -> Option<Color> {
let s = r.s;
let class = s.class(id)?;
let col = || -> Option<Vec<f64>> {
let v = floats(s.field(id, b"_col")?)?;
v.iter().all(|f| f.is_finite()).then_some(v)
};
Some(match &class.0.to_be_bytes() {
b"RGBA" => {
let v = col()?;
Color::Rgb { r: *v.first()?, g: *v.get(1)?, b: *v.get(2)?, a: v.get(3)?.clamp(0.0, 1.0) }
}
b"HSLA" => {
let v = col()?;
let (r2, g, b) = hsl(*v.first()?, *v.get(1)?, *v.get(2)?);
Color::Rgb { r: r2, g, b, a: v.get(3)?.clamp(0.0, 1.0) }
}
b"CMYK" => {
let v = col()?;
Color::Cmyk { c: *v.first()?, m: *v.get(1)?, y: *v.get(2)?, k: *v.get(3)?, a: v.get(4)?.clamp(0.0, 1.0) }
}
b"GRAY" => {
let v = col()?;
Color::Gray { v: *v.first()?, a: v.get(1)?.clamp(0.0, 1.0) }
}
b"LABA" => {
let b = s.bytes(id, b"_col")?;
let u = |i: usize| -> Option<f64> { Some(f64::from(u16::from_le_bytes([*b.get(2 * i)?, *b.get(2 * i + 1)?])) / 65535.0) };
Color::Lab { l: 100.0 * u(0)?, a: 255.0 * u(1)? - 128.0, b: 255.0 * u(2)? - 128.0, alpha: u(3)? }
}
b"Pant" => {
r.warn("spot colours (imported as their RGB equivalent)");
if depth >= MAX_SPOT_CHAIN {
return None;
}
return s
.obj(id, b"srgb")
.and_then(|c| color_within(r, c, depth + 1))
.or_else(|| s.obj(id, b"base").and_then(|c| color_within(r, c, depth + 1)));
}
b"RegC" => Color::Cmyk { c: 1.0, m: 1.0, y: 1.0, k: 1.0, a: 1.0 },
_ => {
r.warn("colours in an unknown model");
return None;
}
})
}
pub(crate) fn hsl(h: f64, s: f64, l: f64) -> (f64, f64, f64) {
let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
let hp = (h.rem_euclid(1.0)) * 6.0;
let x = c * (1.0 - (hp % 2.0 - 1.0).abs());
let (r, g, b) = match hp as u32 {
0 => (c, x, 0.0),
1 => (x, c, 0.0),
2 => (0.0, c, x),
3 => (0.0, x, c),
4 => (x, 0.0, c),
_ => (c, 0.0, x),
};
let m = l - c / 2.0;
(r + m, g + m, b + m)
}
/// A fill descriptor (`FDsc`) or, in Affinity 1 files, a bare `FilS`; `local` maps the node's
/// space to document pixels.
pub(crate) fn descriptor(r: &mut Reader, id: ObjId, local: Affine) -> Option<Paint> {
let s = r.s;
let (fill, unit) = if s.is(id, b"FDsc") {
let unit = s.floats::<6>(id, b"FDeX").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY);
(s.obj(id, b"FDeF")?, unit)
} else {
(id, Affine::IDENTITY)
};
match &s.class(fill)?.0.to_be_bytes() {
b"FilN" => Some(Paint::None),
b"FilS" => Some(Paint::Solid(color(r, s.obj(fill, b"Colr")?)?)),
b"FilG" => {
let kind = match s.enumeration(fill, b"Type").map(|(i, _)| i) {
Some(0) | None => GradientKind::Linear,
Some(1 | 2) => GradientKind::Radial,
Some(3) => GradientKind::Conical,
Some(_) => {
r.warn("gradients of an unknown kind (imported as linear)");
GradientKind::Linear
}
};
let grad = s.obj(fill, b"Grad")?;
let cols = s.objs(grad, b"Cols");
// Convert only the positions that pair with a colour, as shared attributes repeat this per run.
let posn: &[Value] = match s.field(grad, b"Posn") {
Some(Value::Array(a)) => a,
_ => &[],
};
let mut stops = Vec::new();
for (c, p) in cols.iter().zip(posn.iter().filter_map(floats)).take(MAX_STOPS) {
let color = color(r, *c)?;
let offset = p.first().copied().filter(|v| v.is_finite()).unwrap_or(0.0).clamp(0.0, 1.0);
let bias = p.get(1).copied().filter(|v| v.is_finite()).unwrap_or(0.5).clamp(0.0, 1.0);
stops.push(Stop { offset, color, bias });
}
if stops.is_empty() {
return Some(Paint::None);
}
Some(Paint::Gradient(Gradient { kind, stops, transform: unit.then(local) }))
}
b"FilB" => {
r.warn("bitmap and pattern fills (left empty)");
Some(Paint::None)
}
_ => {
r.warn("fills of an unknown kind (left empty)");
Some(Paint::None)
}
}
}
fn current(r: &Reader, id: ObjId, list: &[u8; 4], index: &[u8; 4]) -> Option<ObjId> {
let items = r.s.objs(id, list);
let i = r.s.int(id, index).and_then(|i| usize::try_from(i).ok()).unwrap_or(0);
items.get(i).or_else(|| items.first()).copied()
}
/// The fills of `list` (artboard backgrounds use `BFFl`).
pub(crate) fn fills(r: &mut Reader, id: ObjId, local: Affine, list: &[u8; 4]) -> Vec<crate::paint::Paint> {
let d = current(r, id, list, b"BFCr").or_else(|| r.s.obj(id, b"BFil"));
d.and_then(|d| descriptor(r, d, local)).filter(|p| *p != Paint::None).into_iter().collect()
}
/// A node's fill and stroke. `local` maps the node's space to document pixels.
pub(crate) fn node_paint(r: &mut Reader, id: ObjId, local: Affine) -> (Vec<Paint>, Vec<Stroke>) {
let s = r.s;
let fills = fills(r, id, local, b"BFFl");
if r.s.objs(id, b"BFFl").len() > 1 || r.s.objs(id, b"LIFl").len() > 1 {
r.warn("several fills or strokes on one object (only the active one is imported)");
}
if s.f64(id, b"FOpc").is_some_and(|o| o < 0.999) {
r.warn("fill opacity (imported at full fill opacity)");
}
if s.obj(id, b"Trns").is_some() {
r.warn("transparency gradients (imported without them)");
}
let (pen, style) = if s.field(id, b"LIFl").is_some() {
(current(r, id, b"LIFl", b"LICr"), current(r, id, b"LILn", b"LICr"))
} else {
(s.obj(id, b"PFil"), s.obj(id, b"LSty"))
};
let behind = matches!(s.field(id, b"DrwO"), Some(Value::Array(a)) if a.first() == Some(&Value::Enum { id: 1, version: 0 }));
let stroke = match (pen, style) {
(Some(pen), Some(style)) => stroke(r, pen, style, local, behind),
_ => None,
};
(fills, stroke.into_iter().collect())
}
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 {
return None;
}
let lsty = if s.is(ldsc, b"LDsc") { s.obj(ldsc, b"LDeL")? } else { ldsc };
let weight = s.f64(lsty, b"Wght").filter(|w| *w > 0.0 && *w < 1e7)?;
// "Scale with object": the weight follows the object's transform; otherwise it is absolute.
let scales = s.bool(ldsc, b"LDSc").unwrap_or(true);
let width = if scales { weight * local.scale() } else { weight };
let data = s.bytes(lsty, b"Data").unwrap_or_default();
// 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
// looks wrong.
let (cap, join) = match (data.get(8).copied(), data.get(9).copied(), data.get(10).copied()) {
(Some(0), Some(0), _) => (Cap::Butt, Join::Miter),
(Some(0), Some(_), _) => (Cap::Butt, Join::Round),
(Some(2), Some(2), Some(3)) => (Cap::Round, Join::Round),
(Some(2), Some(2), _) => (Cap::Square, Join::Round),
_ => (Cap::Round, Join::Round),
};
let align = match s.int(ldsc, b"LDSa") {
Some(1) => Align::Inside,
Some(2) => Align::Outside,
_ => Align::Center,
};
let dash = match s.field(lsty, b"Patn") {
Some(Value::Array(a)) if a.len() > MAX_DASHES => {
r.warn("dash patterns of more than 64 lengths (imported as solid lines)");
None
}
Some(Value::Array(a)) if data.get(10) == Some(&2) || !a.is_empty() => {
let mut v: Vec<f64> = a.iter().filter_map(|x| if let Value::Float(f) = x { Some(*f) } else { None }).collect();
while v.len() >= 2 && v.iter().rev().take(2).all(|x| *x == 0.0) {
v.truncate(v.len() - 2);
}
let valid = !v.is_empty() && v.iter().all(|x| x.is_finite() && *x >= 0.0) && v.iter().any(|x| *x > 0.0);
valid.then(|| (v.iter().map(|x| x * width).collect(), s.f64(lsty, b"Phse").unwrap_or(0.0) * width))
}
_ => None,
};
if s.obj(lsty, b"Brus").is_some() {
r.warn("brush strokes (imported as plain strokes)");
}
// The pressure curve sets the width along the path; one at full width all along is a plain
// stroke. Every curve read counts against the document's points: one at full width counts its
// points, and one that can't be read counts the most a curve may have. Once the points are
// used up the rest are not read.
const BEYOND: &str = "pressure strokes beyond the pressure points one document may hold (imported as plain strokes)";
let ldep = s.obj(ldsc, b"LDeP");
if ldep.is_some() && r.budget.pressure == 0 {
r.warn(BEYOND);
}
let profile = match ldep.filter(|_| r.budget.pressure > 0).map(|p| crate::geometry::pressure(r, p)) {
Some(Some(points)) => match r.budget.pressure.checked_sub(points.len()) {
Some(left) => {
r.budget.pressure = left;
(!points.iter().all(|(_, w)| (w - 1.0).abs() < 1e-9)).then_some(points)
}
None => {
r.budget.pressure = 0;
r.warn(BEYOND);
None
}
},
Some(None) => {
r.budget.pressure = r.budget.pressure.saturating_sub(crate::geometry::MAX_PRESSURE_CURVE_POINTS);
r.warn("pressure strokes whose pressure curve can't be read (imported as plain strokes)");
None
}
None => None,
};
Some(Stroke { paint, width, cap, join, miter_limit, align, dash, behind, profile })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hsl_lightness_1_is_white_and_0_is_black() {
assert_eq!(hsl(0.0, 1.0, 1.0), (1.0, 1.0, 1.0));
assert_eq!(hsl(0.5, 1.0, 1.0), (1.0, 1.0, 1.0));
assert_eq!(hsl(0.0, 1.0, 0.0), (0.0, 0.0, 0.0));
let (r, g, b) = hsl(0.0, 1.0, 0.5);
assert!((r - 1.0).abs() < 1e-6 && g.abs() < 1e-6 && b.abs() < 1e-6);
}
}
-124
View File
@@ -1,124 +0,0 @@
//! The indexed PNG thumbnail Affinity stores next to the archive: Affinity's own render of the
//! document, at most 512 pixels on the long side in real files, never the document itself.
use crate::Error;
use crate::container::header;
pub const MAX_PREVIEW_BYTES: usize = 16 << 20;
pub const MAX_PREVIEW_DIMENSION: u32 = 4096;
/// Borrowed embedded thumbnail, at its own dimensions, never the native document dimensions.
#[derive(Debug)]
pub struct Preview<'a> {
pub png: &'a [u8],
pub width: u32,
pub height: u32,
}
fn bytes_at(bytes: &[u8], offset: usize, length: usize) -> Result<&[u8], Error> {
let end = offset.checked_add(length).ok_or(Error::Malformed("offset overflow"))?;
bytes.get(offset..end).ok_or(Error::Malformed("truncated record"))
}
fn le32(bytes: &[u8], offset: usize) -> Result<u32, Error> {
let b = bytes_at(bytes, offset, 4)?;
Ok(u32::from_le_bytes(b.try_into().map_err(|_| Error::Malformed("integer"))?))
}
fn be32(bytes: &[u8], offset: usize) -> Result<u32, Error> {
let b = bytes_at(bytes, offset, 4)?;
Ok(u32::from_be_bytes(b.try_into().map_err(|_| Error::Malformed("PNG integer"))?))
}
/// Read only the indexed `Thmb` record (identical in container versions 8 to 12), checking every PNG chunk CRC. Compressed
/// pixels must still be validated by the consuming image decoder, with allocation limits.
pub fn preview(bytes: &[u8]) -> Result<Preview<'_>, Error> {
let h = header(bytes)?;
if h.thumbnail_offset == 0 {
return Err(Error::Unsupported("no embedded preview"));
}
let offset = usize::try_from(h.thumbnail_offset).map_err(|_| Error::Malformed("thumbnail offset overflow"))?;
if offset < 72 {
return Err(Error::Malformed("thumbnail overlaps the file header"));
}
let record = bytes.get(offset..).ok_or(Error::Malformed("thumbnail offset outside file"))?;
if bytes_at(record, 0, 8)? != b"\xff\xff\xff\xffThmb" {
return Err(Error::Malformed("missing indexed thumbnail"));
}
if le32(record, 8)? != 1 || bytes_at(record, 28, 1)? != [1] {
return Err(Error::Unsupported("thumbnail record version or encoding"));
}
let size = usize::try_from(le32(record, 24)?).map_err(|_| Error::Limit("encoded size"))?;
if size > MAX_PREVIEW_BYTES {
return Err(Error::Limit("encoded preview exceeds 16 MiB"));
}
if le32(record, 16)? != 29 || le32(record, 20)? != 0 || u64::from(le32(record, 12)?) != size as u64 + 13 {
return Err(Error::Malformed("inconsistent thumbnail record lengths"));
}
let png = bytes_at(record, 29, size)?;
if bytes_at(png, 0, 8)? != b"\x89PNG\r\n\x1a\n" || be32(png, 8)? != 13 || bytes_at(png, 12, 4)? != b"IHDR" {
return Err(Error::Malformed("thumbnail is not a PNG"));
}
let width = be32(png, 16)?;
let height = be32(png, 20)?;
if width == 0 || height == 0 || width > MAX_PREVIEW_DIMENSION || height > MAX_PREVIEW_DIMENSION {
return Err(Error::Limit("preview dimensions must be 1..4096"));
}
// Bound work by bytes, not a file-supplied chunk count, and require exactly one complete PNG.
let mut pos = 8usize;
let (mut palette, mut image_data, mut image_data_ended) = (false, false, false);
loop {
let length = usize::try_from(be32(png, pos)?).map_err(|_| Error::Limit("PNG chunk length"))?;
let chunk_size = length.checked_add(12).ok_or(Error::Malformed("PNG chunk overflow"))?;
let chunk = bytes_at(png, pos, chunk_size)?;
let checksum_input = bytes_at(chunk, 4, chunk_size - 8)?;
if crc32fast::hash(checksum_input) != be32(chunk, chunk_size - 4)? {
return Err(Error::Malformed("PNG chunk checksum"));
}
let kind = bytes_at(chunk, 4, 4)?;
if !kind.iter().all(u8::is_ascii_alphabetic) {
return Err(Error::Malformed("PNG chunk type"));
}
match kind {
b"IHDR" if pos == 8 => {}
b"IHDR" => return Err(Error::Malformed("duplicate PNG header")),
b"PLTE" => {
if palette || image_data {
return Err(Error::Malformed("PNG palette order"));
}
palette = true;
}
b"IDAT" => {
if image_data_ended {
return Err(Error::Malformed("non-contiguous PNG image data"));
}
image_data = true;
}
b"IEND" if !image_data => return Err(Error::Malformed("PNG has no image data")),
b"IEND" => {}
_ if kind.first().is_some_and(u8::is_ascii_uppercase) => {
return Err(Error::Unsupported("unknown critical PNG chunk"));
}
_ => {}
}
if image_data && kind != b"IDAT" {
image_data_ended = true;
}
// Native sample previews are plain, single-frame PNGs. Pixel allocation limits do not
// necessarily bound aggregate compressed metadata; leave these layouts unsupported.
if matches!(kind, b"zTXt" | b"iTXt" | b"iCCP") {
return Err(Error::Unsupported("compressed PNG metadata in the preview"));
}
if matches!(kind, b"acTL" | b"fcTL" | b"fdAT") {
return Err(Error::Unsupported("animated PNG preview"));
}
pos = pos.checked_add(chunk_size).ok_or(Error::Malformed("PNG offset overflow"))?;
if kind == b"IEND" {
if length != 0 || pos != png.len() {
return Err(Error::Malformed("PNG end or trailing data"));
}
break;
}
}
Ok(Preview { png, width, height })
}
-650
View File
@@ -1,650 +0,0 @@
//! Pixel layers (`Rstr`) and placed images (`ImgN`). A placed image keeps its original file
//! (`DyBm.Bckg` → archive entry `c/N`), which is imported as is; other pixels are planar tiles of
//! 256 bytes × 256 rows per channel, each its own archive entry.
use std::collections::HashMap;
use crate::Error;
use crate::model::{Affine, Image, Pixels, Reader};
use crate::stream::{self, ObjId, Tag, Value};
/// Pixels decoded per layer (the content's bounding box, not the canvas).
const MAX_PIXELS: u64 = 64 << 20;
const TILE: usize = 256;
const TILE_BYTES: usize = TILE * TILE;
struct CachedPixels {
tiles: HashMap<String, Vec<u8>>,
source: Option<(usize, Vec<u8>)>,
}
pub(crate) fn node_image(r: &mut Reader, id: ObjId, world: Affine) -> Result<Option<Image>, Error> {
let s = r.s;
let Some(bitmap) = s.obj(id, b"Bitm") else {
r.warn("pixel layers without pixels");
return Ok(None);
};
if !s.is(bitmap, b"DyBm") {
r.warn("pixel layers stored in an unknown way");
return Ok(None);
}
let width = s.int(bitmap, b"BmpW").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
let height = s.int(bitmap, b"BmpH").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
if width == 0 || height == 0 {
return Ok(None);
}
// A placed image: the original file, byte for byte.
let has_tiles = s.field(bitmap, b"Sta1").is_some();
if let Some(name) = s.entry(bitmap, b"Bckg").filter(|n| !n.is_empty())
&& (s.is(id, b"ImgN") || !has_tiles)
{
match original(r, name) {
// 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)"),
}
}
// The content's bounding box, so a mostly empty canvas-sized layer stays small.
let crop = s
.field(id, b"BitR")
.and_then(|v| if let Value::Ints(i) = v { Some(i.clone()) } else { None })
.filter(|v| v.len() == 4 && v[0] > i64::from(i32::MIN) + 1)
.map(|v| {
(
v[0].clamp(0, i64::from(width)) as u32,
v[1].clamp(0, i64::from(height)) as u32,
v[2].clamp(0, i64::from(width)) as u32,
v[3].clamp(0, i64::from(height)) as u32,
)
})
.unwrap_or((0, 0, width, height));
let source_tiles_only =
(1..=4).all(|c| matches!(s.field(bitmap, &tag(b"Sta", c)), Some(Value::Array(a)) if !a.is_empty() && a.iter().all(|v| *v == Value::UInt(5))));
if s.field(bitmap, b"Bckg").is_some() && has_tiles && !source_tiles_only && !s.is(id, b"ImgN") {
r.warn("edited placed images use their stored pixels");
}
decode(r, bitmap, crop, world)
}
/// 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);
let height = s.int(bitmap, b"BmpH").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
if width == 0 || height == 0 || !matches!(s.enumeration(bitmap, b"Frmt"), Some((6 | 7, _))) {
r.warn("pixel masks in an unknown format (imported without them)");
return Ok(None);
}
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.
pub(crate) fn embedded(r: &mut Reader, id: ObjId, world: Affine) -> Result<Option<Image>, Error> {
let s = r.s;
let Some(cache) = s.obj(id, b"Bitm").and_then(|e| s.obj(e, b"Cach")) else { return Ok(None) };
let Some(bbox) = s.obj(id, b"Bitm").and_then(|e| s.floats::<4>(e, b"ChBB")) else { return Ok(None) };
let width = s.int(cache, b"BmpW").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
let height = s.int(cache, b"BmpH").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
if width == 0 || height == 0 {
return Ok(None);
}
let [x0, y0, x1, y1] = bbox;
// The instance's origin is the centre of the embedded content (checked against the
// thumbnails of documents with embedded documents and SVG files).
let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
let place = Affine([(x1 - x0) / f64::from(width), 0.0, 0.0, (y1 - y0) / f64::from(height), x0 - cx, y0 - cy]).then(world);
decode(r, cache, (0, 0, width, height), place)
}
/// Decode the tiles of a bitmap inside `crop` (pixels), placed by `world` (bitmap pixels to document).
fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affine) -> Result<Option<Image>, Error> {
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);
// 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.budget.pixels.checked_sub(pixels) else {
r.warn("pixel layers beyond the pixels one document may hold");
return Ok(None);
};
r.budget.pixels = left;
let format = s.enumeration(bitmap, b"Frmt").map(|(f, _)| f);
let (channels, bps) = match format {
Some(0) => (4, 1),
Some(1) => (4, 2),
Some(6) => (1, 1),
Some(7) => (1, 2),
Some(4) => (5, 1),
_ => {
r.warn("pixel layers in grey, Lab or 32-bit formats");
return Ok(None);
}
};
let mut planes = Vec::with_capacity(channels);
// Current .af documents can keep level-zero tiles in their embedded JPEG/PNG instead of
// duplicating the pixels. State 5 is checked in the public Patchy embedded-jpeg fixture.
// Support only the observed, same-size RGBA8 image with a zero bitmap origin; other
// representations still receive the existing loss warning rather than guessing a mapping.
let needs_source = (1..=channels).any(|c| matches!(s.field(bitmap, &tag(b"Sta", c)), Some(Value::Array(a)) if a.contains(&Value::UInt(5))));
let source = if needs_source && format == Some(0) && s.int(bitmap, b"LInf") == Some(0) && s.int(bitmap, b"TInf") == Some(0) {
if let Some(name) = s.entry(bitmap, b"Bckg") {
let width = s.int(bitmap, b"BmpW").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
let height = s.int(bitmap, b"BmpH").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
// The whole source is decoded for every layer that reads it, so charge it all, not only the crop.
let Some(left) = r.budget.pixels.checked_sub(u64::from(width) * u64::from(height)) else {
return Err(Error::Limit("embedded image pixels"));
};
r.budget.pixels = left;
match original(r, name).and_then(|bytes| source_pixels(&bytes, width, height)) {
Ok(pixels) => Some((width as usize, pixels)),
Err(Error::Limit(e)) => return Err(Error::Limit(e)),
Err(_) => None,
}
} else {
None
}
} 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), 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 {
f64::from(u16::from_le_bytes([p.get(2 * i).copied().unwrap_or(0), p.get(2 * i + 1).copied().unwrap_or(0)])) / 65535.0
} else {
f64::from(p.get(i).copied().unwrap_or(0)) / 255.0
}
};
for (i, px) in rgba.as_chunks_mut::<4>().0.iter_mut().enumerate() {
let v: Vec<f64> = planes.iter().map(|p| sample(p, i)).collect();
let (r2, g, b, a) = if channels == 1 { (v[0], v[0], v[0], 1.0) } else { (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));
}
Ok(Some(Image { width: w, height: h, pixels: Pixels::Rgba8(rgba), transform }))
}
/// The original image file: entry `c/N` is a stream whose root (`Blck`) holds it as `Data`.
fn original(r: &mut Reader, name: &str) -> Result<Vec<u8>, Error> {
let bytes = r.archive.read(name)?;
let s = stream::parse(&bytes)?;
let root = s.object(s.root).ok_or(Error::Malformed("embedded image"))?;
match root.get(Tag::of(b"Data")) {
Some(Value::Blob(b)) if !b.is_empty() => Ok(b.clone()),
_ => Err(Error::Malformed("embedded image without data")),
}
}
/// Decode the source under the same pixel budget as tiled layers, checking its actual
/// dimensions before allocating decoded pixels. Only JPEG and PNG sources are supported.
fn source_pixels(bytes: &[u8], width: u32, height: u32) -> Result<Vec<u8>, Error> {
use image::{ImageFormat, ImageReader};
use std::io::Cursor;
if u64::from(width) * u64::from(height) > MAX_PIXELS {
return Err(Error::Limit("embedded image pixels"));
}
let mut reader = ImageReader::new(Cursor::new(bytes)).with_guessed_format().map_err(|_| Error::Malformed("embedded image format"))?;
if !matches!(reader.format(), Some(ImageFormat::Jpeg | ImageFormat::Png)) {
return Err(Error::Unsupported("embedded source pixel format"));
}
let dimensions = ImageReader::new(Cursor::new(bytes))
.with_guessed_format()
.map_err(|_| Error::Malformed("embedded image format"))?
.into_dimensions()
.map_err(|_| Error::Malformed("embedded image dimensions"))?;
if dimensions != (width, height) || width == 0 || height == 0 {
return Err(Error::Malformed("embedded source and bitmap dimensions differ"));
}
let mut limits = image::Limits::default();
limits.max_image_width = Some(width);
limits.max_image_height = Some(height);
limits.max_alloc = Some(MAX_PIXELS * 8);
reader.limits(limits);
let decoded = reader.decode().map_err(|e| match e {
image::ImageError::Limits(_) => Error::Limit("embedded image decoding"),
_ => Error::Malformed("embedded image decoding"),
})?;
Ok(decoded.into_rgba8().into_raw())
}
fn tag(prefix: &[u8; 3], c: usize) -> [u8; 4] {
[prefix[0], prefix[1], prefix[2], b'0' + c as u8]
}
/// 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));
// A grid larger than the bitmap has tiles no crop can reach: don't convert or scan them on every reference.
let tiles = width.saturating_mul(bps).div_ceil(TILE).saturating_mul(height.div_ceil(TILE));
let states: Vec<u8> = match s.field(bitmap, &tag(b"Sta", c)) {
Some(Value::Array(a)) => a.iter().take(tiles).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 (tw, th, states) = grid(s, bitmap, c, bps);
let tiles = s.objs(bitmap, &tag(b"Idx", c));
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;
for (t, state) in states.iter().enumerate().take(tw.saturating_mul(th)) {
let (ty, tx) = (t / tw.max(1), t % tw.max(1));
let fill = match state {
0 | 1 => None,
2 => Some(0xFF),
4 => {
let Some(blck) = tiles.get(next).copied() else { return Err(Error::Malformed("missing pixel tile")) };
next += 1;
let (ox, oy) = (tx * TILE, ty * TILE);
if ox >= bx1 || ox + TILE <= bx0 || oy >= py1 || oy + TILE <= py0 {
continue;
}
let name = s.entry(blck, b"Data").ok_or(Error::Malformed("pixel tile without data"))?.to_string();
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") {
data = data.get(21..21 + TILE_BYTES).ok_or(Error::Malformed("pixel tile"))?.to_vec();
}
if data.len() != TILE_BYTES {
return Err(Error::Malformed("pixel tile size"));
}
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, &at, (ox, oy), |x, y| tile.get(y * TILE + x).copied().unwrap_or(0));
continue;
}
3 => {
r.warn("32-bit pixel layers");
None
}
5 => {
if let Some((source_width, pixels)) = source.as_ref() {
let (ox, oy) = (tx * TILE, ty * TILE);
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)");
None
}
_ => return Err(Error::Malformed("unknown pixel tile state")),
};
if let Some(v) = fill {
copy(&mut out, &at, (tx * TILE, ty * TILE), |_, _| v);
}
}
Ok(out)
}
/// 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) {
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);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
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());
pixels.write_to(&mut bytes, image::ImageFormat::Png).unwrap();
bytes.into_inner()
}
/// 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(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..=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(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}")))])]),
));
}
let mut crop = vec![0x17];
crop.extend(tag(b"BitR").0.to_le_bytes());
crop.extend([250i32, 0, 270, 2].into_iter().flat_map(i32::to_le_bytes));
let raster = F::Def(
10,
vec![tag(b"Rstr")],
vec![
(tag(b"Bitm"), F::Obj(tag(b"DyBm"), bitmap)),
(tag(b"BitR"), F::Raw(crop)),
(tag(b"Xfrm"), F::F64s(vec![0.0, -2.0, 10.0, 2.0, 0.0, 20.0])),
],
);
let spread = F::Def(11, vec![tag(b"Sprd")], vec![(tag(b"Chld"), F::Shared(vec![raster]))]);
let doc = synth::stream(&[(tag(b"DocR"), F::Obj(tag(b"DocN"), vec![(tag(b"Chld"), F::Shared(vec![spread]))]))]);
let mut blob = vec![0x2d];
blob.extend(tag(b"Data").0.to_le_bytes());
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, 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]
fn source_tiles_and_cached_edits_survive_crop_rotation_and_tile_boundaries() {
let doc = crate::read(&fixture(&png(), 0), crate::Limits::default()).unwrap();
assert_eq!(doc.warnings, ["edited placed images use their stored pixels"]);
let Kind::Image(image) = &doc.spreads[0].nodes[0].kind else { panic!() };
assert_eq!((image.width, image.height), (20, 2));
assert_eq!(image.transform, Affine([0.0, 2.0, -2.0, 0.0, 10.0, 520.0]));
let Pixels::Rgba8(pixels) = &image.pixels else { panic!() };
for y in 0..2 {
for x in 0..20 {
let expected = if x + 250 < 256 { [((x + 250) % 251) as u8, (y * 31) as u8, 77, 255] } else { [200, 10, 30, 255] };
assert_eq!(&pixels[(y * 20 + x) * 4..(y * 20 + x + 1) * 4], &expected);
}
}
}
#[test]
fn unreadable_sources_and_unknown_origins_preserve_cached_edits_and_warn() {
for bytes in [fixture(b"invalid image", 0), fixture(&png(), 1)] {
let doc = crate::read(&bytes, crate::Limits::default()).unwrap();
assert!(doc.warnings.iter().any(|w| w == "pixel layers drawn from an embedded image (left empty) (4×)"));
let Kind::Image(image) = &doc.spreads[0].nodes[0].kind else { panic!() };
let Pixels::Rgba8(pixels) = &image.pixels else { panic!() };
assert_eq!(&pixels[..4], &[0, 0, 0, 0]);
assert_eq!(&pixels[6 * 4..7 * 4], &[200, 10, 30, 255]);
}
}
/// Pixel layers of one flat colour: nothing in the archive but `doc.dat` (state 2 is a tile
/// of 0xFF). Written by hand for the reader, like [`fixture`].
fn flat_layers(count: u32, side: i32) -> Vec<u8> {
let layer = |i: u32| {
let mut bitmap = vec![(tag(b"Frmt"), F::Enum(0, 0)), (tag(b"BmpW"), F::I32(side)), (tag(b"BmpH"), F::I32(side))];
for c in 1..=4 {
bitmap.push((tag(&super::tag(b"TWi", c)), F::I32(1)));
bitmap.push((tag(&super::tag(b"THi", c)), F::I32(1)));
bitmap.push((tag(&super::tag(b"Sta", c)), F::U8s(vec![2])));
}
F::Def(10 + i, vec![tag(b"Rstr")], vec![(tag(b"Bitm"), F::Obj(tag(b"DyBm"), bitmap))])
};
let spread = F::Def(1, vec![tag(b"Sprd")], vec![(tag(b"Chld"), F::Shared((0..count).map(layer).collect()))]);
let doc = synth::stream(&[(tag(b"DocR"), F::Obj(tag(b"DocN"), vec![(tag(b"Chld"), F::Shared(vec![spread]))]))]);
synth::container(&[("doc.dat", &doc, Method::Zlib)], None)
}
/// #1704: the pixels of all pixel layers together are bounded, also when the archive holds
/// no data for them: the layers past the allowance are left out with a note.
#[test]
fn pixel_layers_share_one_allowance_for_the_whole_document() {
let file = flat_layers(5, 16);
let layers = |pixels: u64| {
let doc = crate::model::read_within(&file, crate::Limits::default(), pixels).unwrap();
let n = doc.spreads[0].nodes.iter().filter(|n| matches!(n.kind, Kind::Image(_))).count();
(n, doc.warnings)
};
assert_eq!(layers(5 * 256), (5, vec![]));
let (kept, warnings) = layers(3 * 256 + 255);
assert_eq!(kept, 3);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("pixel layers beyond the pixels one document may hold"), "{warnings:?}");
assert_eq!(crate::read(&file, crate::Limits::default()).unwrap().spreads[0].nodes.len(), 5);
}
#[test]
fn embedded_source_dimensions_and_pixel_budget_are_checked_before_decode() {
let bytes = png();
assert!(matches!(source_pixels(&bytes, 299, 2), Err(Error::Malformed(_))));
assert!(matches!(source_pixels(&bytes, u32::MAX, u32::MAX), Err(Error::Limit(_))));
assert_eq!(source_pixels(&bytes, 300, 2).unwrap().len(), 300 * 2 * 4);
}
}
-292
View File
@@ -1,292 +0,0 @@
//! Parametric shapes: Affinity stores the shape's parameters and its box, never the outline.
//! Formulas were fitted to Affinity's own renders (the document thumbnails) of public files.
use std::f64::consts::{FRAC_PI_2, PI, TAU};
use crate::model::{Affine, Path, Point, Reader, SubPath};
use crate::stream::{ObjId, Tag, Value};
/// Handle length of a quarter circle of radius 1.
const KAPPA: f64 = 0.552_284_749_830_793_4;
/// Vertices of a polygon or star.
const MAX_SIDES: i64 = 10_000;
/// The last value of a tag: a shape changed from one kind to another keeps a stale first block.
fn last<'a>(r: &'a Reader, id: ObjId, tag: &[u8; 4]) -> Option<&'a Value> {
r.s.object(id)?.get_last(Tag::of(tag))
}
fn num(r: &Reader, id: ObjId, tag: &[u8; 4]) -> Option<f64> {
match last(r, id, tag)? {
Value::Float(f) if f.is_finite() => Some(*f),
Value::UInt(u) => Some(*u as f64),
Value::Int(i) => Some(*i as f64),
_ => None,
}
}
fn sides(r: &Reader, id: ObjId, tag: &[u8; 4], default: f64) -> usize {
let n = num(r, id, tag).unwrap_or(default).round();
n.clamp(3.0, MAX_SIDES as f64) as usize
}
fn pt(x: f64, y: f64) -> Point {
Point { x, y }
}
fn polygon(pts: impl IntoIterator<Item = Point>) -> Option<SubPath> {
let mut it = pts.into_iter();
let start = it.next()?;
let segments = it.map(|p| [p, p, p]).collect();
Some(SubPath { start, segments, closed: true })
}
/// Outline of `shape` (a `Shpe` object) in the node box `b` = (x0, y0, x1, y1).
pub(crate) fn shape(r: &mut Reader, shape: ObjId, b: [f64; 4], world: Affine) -> Option<Path> {
let [x0, y0, x1, y1] = b;
let (w, h) = (x1 - x0, y1 - y0);
let (cx, cy, rx, ry) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0, w / 2.0, h / 2.0);
let on_ellipse = |a: f64, f: f64| pt(cx + f * rx * a.cos(), cy + f * ry * a.sin());
let class = r.s.class(shape)?;
let sub = match &class.0.to_be_bytes() {
b"ShNR" | b"ShRR" => {
// Only the four corners' entries can matter: longer arrays are read no further.
let types: Vec<Option<u16>> = match last(r, shape, b"CTyp") {
Some(Value::Array(a)) => a.iter().take(4).map(|t| if let Value::Enum { id, .. } = t { Some(*id) } else { None }).collect(),
_ => Vec::new(),
};
let radii: Vec<f64> = match last(r, shape, b"ShCR") {
Some(Value::Floats(v)) => v.iter().take(4).map(|f| if f.is_finite() && *f > 0.0 { *f } else { 0.0 }).collect(),
_ => Vec::new(),
};
// Corners follow the first one (top left) unless they were unlocked; then each has
// its own radius and type, clockwise from the top left.
let locked = !matches!(last(r, shape, b"Lock"), Some(Value::Bool(false)));
let corners: [(f64, Option<u16>); 4] = std::array::from_fn(|i| {
let i = if locked { 0 } else { i };
(radii.get(i).copied().unwrap_or(0.0), types.get(i).copied().flatten())
});
if types.is_empty() || corners.iter().all(|(f, _)| *f == 0.0) {
polygon([pt(x0, y0), pt(x1, y0), pt(x1, y1), pt(x0, y1)])?
} else {
if corners.iter().any(|(f, t)| *f > 0.0 && !matches!(t, Some(0 | 1 | 4))) {
r.warn("rectangles with concave or other corner types (imported with round corners)");
}
let absolute = r.s.bool(shape, b"AbSz") == Some(true);
let [a, b2, c, d, ..] = world.0;
let (sx, sy) = ((a * a + b2 * b2).sqrt(), (c * c + d * d).sqrt());
cornered_rect(
b,
corners.map(|(f, t)| {
let (crx, cry) = if !absolute {
(f * w.min(h), f * w.min(h))
} else if sx > 0.0 && sy > 0.0 {
// An absolute radius is in document pixels, whatever the node's scale.
(f / sx, f / sy)
} else {
(0.0, 0.0)
};
(crx, cry, t == Some(1))
}),
)
}
}
b"ShpE" => ellipse(cx, cy, rx, ry),
b"ShPy" => {
let n = sides(r, shape, b"Side", 5.0);
let a = |i: usize| -FRAC_PI_2 + TAU * i as f64 / n as f64;
if r.s.object(shape).is_some_and(|_| matches!(last(r, shape, b"Smth"), Some(Value::Bool(true)))) {
// Smoothed: tangent handles of length curv·4/3·tan(π/2n) along the inscribed ellipse.
let k = num(r, shape, b"Curv").unwrap_or(0.0) * 4.0 / 3.0 * (PI / (2.0 * n as f64)).tan();
let v = |i: usize| on_ellipse(a(i), 1.0);
let t = |i: usize| pt(-rx * a(i).sin(), ry * a(i).cos());
let segments = (0..n)
.map(|i| {
let (p, q, tp, tq) = (v(i), v(i + 1), t(i), t(i + 1));
[pt(p.x + k * tp.x, p.y + k * tp.y), pt(q.x - k * tq.x, q.y - k * tq.y), q]
})
.collect();
SubPath { start: v(0), segments, closed: true }
} else {
polygon((0..n).map(|i| on_ellipse(a(i), 1.0)))?
}
}
b"ShSt" => {
let n = sides(r, shape, b"Pnts", 5.0);
let q = num(r, shape, b"IRad").unwrap_or(0.382).clamp(0.0, 1.0);
if [b"CrcI", b"CrcO", b"CrvL", b"CrvR"].iter().any(|t| num(r, shape, t).is_some_and(|v| v.abs() > 1e-9)) {
r.warn("stars with rounded or curved points (imported with sharp points)");
}
polygon((0..2 * n).map(|i| on_ellipse(-FRAC_PI_2 + PI * i as f64 / n as f64, if i % 2 == 0 { 1.0 } else { q })))?
}
b"ShSS" => {
let n = sides(r, shape, b"Side", 5.0);
let ri = num(r, shape, b"COut").unwrap_or(0.5).clamp(0.0, 1.0);
let step = PI / n as f64;
let (l, hw, inner) = (step.cos(), ri * step.sin(), ri * step.cos());
polygon((0..n).flat_map(|k| {
let phi = -FRAC_PI_2 + step + TAU * k as f64 / n as f64;
let (c, s) = (phi.cos(), phi.sin());
[(inner, -hw), (l, -hw), (l, hw), (inner, hw)].map(|(u, v)| pt(cx + rx * (u * c - v * s), cy + ry * (u * s + v * c)))
}))?
}
b"ShPi" => {
let raw_start = num(r, shape, b"AngS").unwrap_or(0.0);
let raw_end = num(r, shape, b"AngE").unwrap_or(TAU);
// Normalize in constant time. Repeatedly adding TAU can hang on finite values
// large enough that floating-point addition no longer changes the endpoint.
let start = raw_start.rem_euclid(TAU);
let delta = raw_end - raw_start;
let sweep = if raw_end >= raw_start {
delta.min(TAU)
} else if delta.is_finite() {
delta.rem_euclid(TAU)
} else {
(raw_end.rem_euclid(TAU) - start).rem_euclid(TAU)
};
let inner = num(r, shape, b"IRad").unwrap_or(0.0).clamp(0.0, 1.0);
pie(cx, cy, rx, ry, start, start + sweep, inner)
}
b"ShpT" => {
let p = num(r, shape, b"Pos ").unwrap_or(0.5);
polygon([pt(x0 + p * w, y0), pt(x1, y1), pt(x0, y1)])?
}
b"ShTz" => {
let (l, rr) = (num(r, shape, b"PosL").unwrap_or(0.25), num(r, shape, b"PosR").unwrap_or(0.75));
polygon([pt(x0 + l * w, y0), pt(x0 + rr * w, y0), pt(x1, y1), pt(x0, y1)])?
}
_ => {
r.warn("cloud, heart, cog, callout, arrow, tear and other special shapes (imported as their bounding ellipse)");
ellipse(cx, cy, rx, ry)
}
};
Some(Path { subpaths: vec![sub] })
}
pub(crate) fn rectangle(x0: f64, y0: f64, x1: f64, y1: f64) -> SubPath {
SubPath { start: pt(x0, y0), segments: [pt(x1, y0), pt(x1, y1), pt(x0, y1)].map(|p| [p, p, p]).to_vec(), closed: true }
}
fn ellipse(cx: f64, cy: f64, rx: f64, ry: f64) -> SubPath {
let (kx, ky) = (KAPPA * rx, KAPPA * ry);
SubPath {
start: pt(cx + rx, cy),
segments: vec![
[pt(cx + rx, cy + ky), pt(cx + kx, cy + ry), pt(cx, cy + ry)],
[pt(cx - kx, cy + ry), pt(cx - rx, cy + ky), pt(cx - rx, cy)],
[pt(cx - rx, cy - ky), pt(cx - kx, cy - ry), pt(cx, cy - ry)],
[pt(cx + kx, cy - ry), pt(cx + rx, cy - ky), pt(cx + rx, cy)],
],
closed: true,
}
}
/// A rectangle whose corners (top left, top right, bottom right, bottom left) are each rounded,
/// or cut straight across, by their (horizontal, vertical) radii. Two radii that don't fit along
/// one side shrink in proportion until they do.
fn cornered_rect([x0, y0, x1, y1]: [f64; 4], corners: [(f64, f64, bool); 4]) -> SubPath {
let fit = |r: f64, pair: f64, side: f64| if pair > side { side * (r / pair) } else { r };
let [tl, tr, br, bl] = corners;
let (w, h) = (x1 - x0, y1 - y0);
let rx = [fit(tl.0, tl.0 + tr.0, w), fit(tr.0, tl.0 + tr.0, w), fit(br.0, bl.0 + br.0, w), fit(bl.0, bl.0 + br.0, w)];
let ry = [fit(tl.1, tl.1 + bl.1, h), fit(tr.1, tr.1 + br.1, h), fit(br.1, tr.1 + br.1, h), fit(bl.1, tl.1 + bl.1, h)];
let (kx, ky) = (rx.map(|r| (1.0 - KAPPA) * r), ry.map(|r| (1.0 - KAPPA) * r));
// (where the corner leaves the previous side, its two handles, where it joins the next side)
let arcs = [
[pt(x1 - rx[1], y0), pt(x1 - kx[1], y0), pt(x1, y0 + ky[1]), pt(x1, y0 + ry[1])],
[pt(x1, y1 - ry[2]), pt(x1, y1 - ky[2]), pt(x1 - kx[2], y1), pt(x1 - rx[2], y1)],
[pt(x0 + rx[3], y1), pt(x0 + kx[3], y1), pt(x0, y1 - ky[3]), pt(x0, y1 - ry[3])],
[pt(x0, y0 + ry[0]), pt(x0, y0 + ky[0]), pt(x0 + kx[0], y0), pt(x0 + rx[0], y0)],
];
let mut segments = Vec::with_capacity(8);
for ([s, c1, c2, e], straight) in arcs.into_iter().zip([tr.2, br.2, bl.2, tl.2]) {
segments.push([s, s, s]);
if s != e {
segments.push(if straight { [e, e, e] } else { [c1, c2, e] });
}
}
SubPath { start: pt(x0 + rx[0], y0), segments, closed: true }
}
/// Elliptical arc from angle `a0` to `a1` (radians, measured y-up as Affinity's pie angles are),
/// as cubic segments of at most a quarter turn.
fn arc(cx: f64, cy: f64, rx: f64, ry: f64, a0: f64, a1: f64, out: &mut Vec<[Point; 3]>) {
let n = ((a1 - a0).abs() / FRAC_PI_2).ceil().clamp(1.0, 8.0) as usize;
let step = (a1 - a0) / n as f64;
let k = 4.0 / 3.0 * (step / 4.0).tan();
let p = |a: f64| pt(cx + rx * a.cos(), cy - ry * a.sin());
let d = |a: f64| pt(-rx * a.sin(), -ry * a.cos());
for i in 0..n {
let (s, e) = (a0 + step * i as f64, a0 + step * (i + 1) as f64);
let (ps, pe, ds, de) = (p(s), p(e), d(s), d(e));
out.push([pt(ps.x + k * ds.x, ps.y + k * ds.y), pt(pe.x - k * de.x, pe.y - k * de.y), pe]);
}
}
fn pie(cx: f64, cy: f64, rx: f64, ry: f64, a0: f64, a1: f64, inner: f64) -> SubPath {
let start = pt(cx + rx * a0.cos(), cy - ry * a0.sin());
let mut segments = Vec::new();
arc(cx, cy, rx, ry, a0, a1, &mut segments);
if inner > 0.0 {
let p = pt(cx + inner * rx * a1.cos(), cy - inner * ry * a1.sin());
segments.push([p, p, p]);
arc(cx, cy, inner * rx, inner * ry, a1, a0, &mut segments);
} else {
let c = pt(cx, cy);
segments.push([c, c, c]);
}
SubPath { start, segments, closed: true }
}
/// A polyline with circular corners of the given radii (Affinity's live corners on curves).
pub(crate) fn fillet(pts: &[(Point, f64)], closed: bool) -> SubPath {
let n = pts.len();
let get = |i: isize| -> Option<Point> {
if closed { pts.get(i.rem_euclid(n as isize) as usize).map(|p| p.0) } else { usize::try_from(i).ok().and_then(|i| pts.get(i)).map(|p| p.0) }
};
let mut corners: Vec<(Point, Point, Point, Point)> = Vec::with_capacity(n);
for (i, (p, radius)) in pts.iter().enumerate() {
let (prev, next) = (get(i as isize - 1), get(i as isize + 1));
let (Some(a), Some(b)) = (prev, next) else {
corners.push((*p, *p, *p, *p));
continue;
};
let (u1, l1) = unit(*p, a);
let (u2, l2) = unit(*p, b);
let cos = (u1.x * u2.x + u1.y * u2.y).clamp(-1.0, 1.0);
let phi = cos.acos();
if *radius <= 0.0 || phi < 1e-6 || (PI - phi) < 1e-6 {
corners.push((*p, *p, *p, *p));
continue;
}
let t = (radius / (phi / 2.0).tan()).min(l1 / 2.0).min(l2 / 2.0);
let r = t * (phi / 2.0).tan();
let k = 4.0 / 3.0 * ((PI - phi) / 4.0).tan() * r;
let s = pt(p.x + u1.x * t, p.y + u1.y * t);
let e = pt(p.x + u2.x * t, p.y + u2.y * t);
corners.push((s, pt(s.x - u1.x * k, s.y - u1.y * k), pt(e.x - u2.x * k, e.y - u2.y * k), e));
}
let Some(first) = corners.first().copied() else { return SubPath { start: pt(0.0, 0.0), segments: vec![], closed } };
let mut segments = Vec::with_capacity(2 * n);
let mut cur = first.0;
let start = cur;
for (i, (s, c1, c2, e)) in corners.iter().enumerate() {
if i > 0 {
segments.push([cur, *s, *s]);
}
if s != e {
segments.push([*c1, *c2, *e]);
}
cur = *e;
}
if closed && cur != start {
segments.push([cur, start, start]);
}
SubPath { start, segments, closed }
}
fn unit(from: Point, to: Point) -> (Point, f64) {
let (dx, dy) = (to.x - from.x, to.y - from.y);
let l = (dx * dx + dy * dy).sqrt();
if l > 0.0 { (pt(dx / l, dy / l), l) } else { (pt(0.0, 0.0), 0.0) }
}
-491
View File
@@ -1,491 +0,0 @@
//! The tagged object stream inside `doc.dat`: a self-describing tree of objects whose fields carry
//! a type code and a four-character tag. Parsing needs no schema; unknown fields are kept, so the
//! document mapping can name what it could not import.
use crate::Error;
use crate::container::Cursor;
/// A four-character tag in its readable order (`Desc`, `Chld`).
#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Tag(pub u32);
impl Tag {
pub const fn of(s: &[u8; 4]) -> Self {
Self(u32::from_be_bytes(*s))
}
}
impl std::fmt::Debug for Tag {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", String::from_utf8_lossy(&self.0.to_be_bytes()))
}
}
impl std::fmt::Display for Tag {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
std::fmt::Debug::fmt(self, f)
}
}
/// Index of an object in [`Stream::objects`].
pub type ObjId = usize;
#[derive(Debug, Clone, PartialEq)]
pub enum Value {
Int(i64),
UInt(u64),
Float(f64),
Bool(bool),
Enum {
id: u16,
version: u16,
},
Str(String),
/// Fixed-length vectors of i32/f32/f64 (points, rectangles, transforms).
Ints(Vec<i64>),
Floats(Vec<f64>),
/// Fixed-size record (curve nodes, ids) or fixed-size struct (colour components).
Bytes(Vec<u8>),
Blob(Vec<u8>),
/// Name of another archive entry (raster tiles, embedded files).
Entry(String),
Flags(u64),
/// `None` is a null object.
Obj(Option<ObjId>),
Array(Vec<Value>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
Root,
/// Defined once and referenced by id elsewhere.
Shared,
Inline,
/// Untagged fields identified by position (curve data).
Positional,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Object {
pub class: Tag,
pub kind: Kind,
/// Class chain, most derived first (`ShpN`, `VNod`, `Node`).
pub chain: Vec<Tag>,
/// Fields in stream order; positional fields have tag 0.
pub fields: Vec<(Tag, Value)>,
/// For objects with many fields: each tag with the positions of its first and last field,
/// sorted by tag, so a lookup isn't a scan of every field (a shared object is looked up
/// again on every visit). Built by [`Object::index`]; empty for small objects.
index: Vec<(Tag, u32, u32)>,
}
/// Objects with more fields than this get an [`Object::index`]; fewer are scanned.
const INDEXED_FIELDS: usize = 16;
impl Object {
pub fn new(class: Tag, kind: Kind, chain: Vec<Tag>, fields: Vec<(Tag, Value)>) -> Self {
let mut o = Self { class, kind, chain, fields, index: Vec::new() };
o.index();
o
}
/// (Re)build the tag index after `fields` changed.
fn index(&mut self) {
self.index.clear();
if self.fields.len() <= INDEXED_FIELDS {
return;
}
let mut index: Vec<(Tag, u32, u32)> = Vec::new();
if index.try_reserve_exact(self.fields.len()).is_err() {
return;
}
// Fields beyond u32 positions are impossible: a stream holds at most MAX_VALUES values.
index.extend(self.fields.iter().enumerate().filter_map(|(i, (t, _))| u32::try_from(i).ok().map(|i| (*t, i, i))));
// Stable: equal tags stay in stream order, so the run of a tag starts at its first field.
index.sort_by_key(|(t, _, _)| *t);
index.dedup_by(|later, first| {
if later.0 == first.0 {
first.2 = later.2;
true
} else {
false
}
});
index.shrink_to_fit();
self.index = index;
}
fn position(&self, tag: Tag, last: bool) -> Option<usize> {
if self.index.is_empty() {
let mut fields = self.fields.iter().enumerate();
let hit = if last { fields.rev().find(|(_, (t, _))| *t == tag) } else { fields.find(|(_, (t, _))| *t == tag) };
return hit.map(|(i, _)| i);
}
let at = self.index.binary_search_by_key(&tag, |(t, _, _)| *t).ok()?;
let (_, first, end) = self.index.get(at)?;
usize::try_from(if last { *end } else { *first }).ok()
}
/// The first field tagged `tag`.
pub fn get(&self, tag: Tag) -> Option<&Value> {
self.fields.get(self.position(tag, false)?).map(|(_, v)| v)
}
/// The last field tagged `tag` (a shape changed from one kind to another keeps a stale first one).
pub fn get_last(&self, tag: Tag) -> Option<&Value> {
self.fields.get(self.position(tag, true)?).map(|(_, v)| v)
}
}
#[derive(Debug)]
pub struct Stream {
pub root: ObjId,
pub objects: Vec<Object>,
}
/// Deepest object nesting accepted; real documents stay well below (each layer level adds three).
pub const MAX_DEPTH: usize = 384;
const MAX_OBJECTS: usize = 8_000_000;
/// Most values (fields and array elements) one stream may decode. A value takes about 40 bytes
/// in memory however few it took in the file (a bool array packs eight in a byte), so the size
/// of the input alone doesn't bound what parsing allocates: a small compressed entry could
/// otherwise ask for tens of gigabytes. Real documents stay far below.
pub const MAX_VALUES: usize = 1 << 24;
struct Parser<'a> {
c: Cursor<'a>,
objects: Vec<Object>,
/// Shared ids, defined at first use and referenced backwards.
shared: std::collections::HashMap<u32, ObjId>,
depth: usize,
/// Values decoded so far, against [`MAX_VALUES`].
values: usize,
}
/// Parse a stream (`00 FF 4B 53` header). Bytes after the root's terminator are ignored.
pub fn parse(bytes: &[u8]) -> Result<Stream, Error> {
let mut c = Cursor::new(bytes, 0);
if c.take(4)? != b"\x00\xffKS" {
return Err(Error::Malformed("document stream signature"));
}
let version = c.u16()?;
if version > 2 {
return Err(Error::Unsupported("document stream version"));
}
let class = Tag(c.u32()?);
c.u16()?;
if version == 2 {
c.u32()?;
}
let mut p = Parser { c, objects: Vec::new(), shared: std::collections::HashMap::new(), depth: 0, values: 0 };
let root = p.push(Object::new(class, Kind::Root, vec![class], Vec::new()))?;
let fields = p.fields(true, class)?;
if let Some(o) = p.objects.get_mut(root) {
o.fields = fields;
o.index();
}
Ok(Stream { root, objects: p.objects })
}
impl Parser<'_> {
fn push(&mut self, o: Object) -> Result<ObjId, Error> {
if self.objects.len() >= MAX_OBJECTS {
return Err(Error::Limit("too many objects"));
}
self.objects.push(o);
Ok(self.objects.len() - 1)
}
/// Counts `n` more values against [`MAX_VALUES`], before anything is allocated for them.
fn spend(&mut self, n: usize) -> Result<(), Error> {
self.values = self.values.checked_add(n).filter(|&v| v <= MAX_VALUES).ok_or(Error::Limit("too many values in the document stream"))?;
Ok(())
}
/// An empty array with room for `n` values, paid for first; an allocation that still fails
/// is an error, not an abort.
fn vec<T>(&mut self, n: usize) -> Result<Vec<T>, Error> {
self.spend(n)?;
let mut v = Vec::new();
v.try_reserve_exact(n).map_err(|_| Error::Limit("not enough memory for the document stream"))?;
Ok(v)
}
/// Element count of an array: each element takes at least `min` bytes, so a count the
/// remaining input cannot hold is rejected before anything is allocated.
fn count(&mut self, min: usize) -> Result<usize, Error> {
let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("array length"))?;
if n.checked_mul(min.max(1)).is_none_or(|bytes| bytes > self.c.remaining()) {
return Err(Error::Malformed("array longer than its data"));
}
Ok(n)
}
fn fields(&mut self, tagged: bool, class: Tag) -> Result<Vec<(Tag, Value)>, Error> {
self.depth += 1;
if self.depth > MAX_DEPTH {
return Err(Error::Limit("objects nested too deeply"));
}
let mut out = Vec::new();
loop {
let t = self.c.u8()?;
let code = t & 0x7F;
if code == 0 {
break;
}
self.spend(1)?;
let tag = if tagged { Tag(self.c.u32()?) } else { Tag(0) };
let v = if t & 0x80 != 0 { self.array(code, class)? } else { self.scalar(code, tagged, class)? };
out.push((tag, v));
}
self.depth -= 1;
Ok(out)
}
fn string(&mut self) -> Result<String, Error> {
let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("string length"))?;
Ok(String::from_utf8_lossy(self.c.take(n)?).into_owned())
}
fn floats(&mut self, n: usize, wide: bool) -> Result<Value, Error> {
let mut v = Vec::with_capacity(n);
for _ in 0..n {
v.push(if wide { f64::from_le_bytes(self.c.array()?) } else { f64::from(f32::from_le_bytes(self.c.array()?)) });
}
Ok(Value::Floats(v))
}
fn scalar(&mut self, code: u8, tagged: bool, class: Tag) -> Result<Value, Error> {
let c = &mut self.c;
Ok(match code {
0x01 => Value::UInt(c.u8()?.into()),
0x02 => Value::UInt(c.u16()?.into()),
0x03 | 0x2f | 0x34 => Value::UInt(c.u32()?.into()),
0x04 => Value::UInt(c.u64()?),
0x05 => Value::Int(i8::from_le_bytes(c.array()?).into()),
0x06 => Value::Int(i16::from_le_bytes(c.array()?).into()),
0x07 => Value::Int(i32::from_le_bytes(c.array()?).into()),
0x08 => Value::Int(i64::from_le_bytes(c.array()?)),
0x09 => Value::Float(f32::from_le_bytes(c.array()?).into()),
0x0a => Value::Float(f64::from_le_bytes(c.array()?)),
0x15..=0x19 => {
let n = usize::from(code - 0x13);
let mut v = Vec::with_capacity(n);
for _ in 0..n {
v.push(i32::from_le_bytes(c.array()?).into());
}
Value::Ints(v)
}
0x1f..=0x23 => return self.floats(usize::from(code - 0x1d), false),
0x24..=0x28 => return self.floats(usize::from(code - 0x22), true),
0x29 => Value::Bool(c.u8()? != 0),
0x2a => Value::Enum { id: c.u16()?, version: c.u16()? },
0x2b | 0x2e => Value::Str(self.string()?),
0x2c => {
let n = usize::from(c.u16()?);
Value::Bytes(c.take(n)?.to_vec())
}
0x35..=0x74 => Value::Bytes(c.take(usize::from(code - 0x34))?.to_vec()),
0x2d => {
let n = usize::try_from(c.u32()?).map_err(|_| Error::Malformed("blob length"))?;
Value::Blob(c.take(n)?.to_vec())
}
0x33 => {
c.u32()?;
let n = usize::try_from(c.u32()?).map_err(|_| Error::Malformed("entry name length"))?;
Value::Entry(String::from_utf8_lossy(c.take(n)?).into_owned())
}
0x75 => {
c.u16()?;
let n = usize::from(c.u8()?);
if n > 8 {
return Err(Error::Malformed("flags wider than 64 bits"));
}
let mut b = [0u8; 8];
b.get_mut(..n).ok_or(Error::Malformed("flags"))?.copy_from_slice(c.take(n)?);
Value::Flags(u64::from_le_bytes(b))
}
0x30 => {
let fields = self.fields(false, class)?;
let id = self.push(Object::new(class, Kind::Positional, Vec::new(), fields))?;
Value::Obj(Some(id))
}
0x31 | 0x32 if !tagged => return Err(Error::Malformed("object without a field tag")),
0x31 => self.shared()?,
0x32 => self.inline(None)?,
_ => return Err(Error::Unsupported("unknown field type in document stream")),
})
}
fn array(&mut self, code: u8, class: Tag) -> Result<Value, Error> {
let min = match code {
0x01 | 0x05 | 0x29 => 1,
0x02 | 0x06 => 2,
0x03 | 0x07 | 0x09 | 0x2f | 0x34 => 4,
0x04 | 0x08 | 0x0a => 8,
0x15..=0x19 => 4 * usize::from(code - 0x13),
0x1f..=0x23 => 4 * usize::from(code - 0x1d),
0x24..=0x28 => 8 * usize::from(code - 0x22),
0x35..=0x74 => usize::from(code - 0x34),
_ => 1,
};
match code {
0x29 => {
let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("array length"))?;
let bits = self.c.take(n.div_ceil(8))?;
let mut v = self.vec(n)?;
v.extend((0..n).map(|i| Value::Bool(bits.get(i / 8).is_some_and(|b| b >> (i % 8) & 1 != 0))));
Ok(Value::Array(v))
}
0x2a => {
let n = self.count(2)?;
let version = self.c.u16()?;
let mut v = self.vec(n)?;
for _ in 0..n {
v.push(Value::Enum { id: self.c.u16()?, version });
}
Ok(Value::Array(v))
}
0x2b | 0x2e => {
self.c.u32()?;
let n = self.count(4)?;
let mut v = self.vec(n)?;
for _ in 0..n {
v.push(Value::Str(self.string()?));
}
Ok(Value::Array(v))
}
0x2c => {
let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("array length"))?;
let size = usize::from(self.c.u16()?);
// Records of no bytes take nothing from the data, whatever their number.
if n > 0 && size == 0 {
return Err(Error::Malformed("array of records without bytes"));
}
if n.checked_mul(size).is_none_or(|b| b > self.c.remaining()) {
return Err(Error::Malformed("array longer than its data"));
}
let mut v = self.vec(n)?;
for _ in 0..n {
v.push(Value::Bytes(self.c.take(size)?.to_vec()));
}
Ok(Value::Array(v))
}
0x32 => {
let n = self.count(1)?;
let class = Tag(self.c.u32()?);
self.c.u16()?;
let mut v = self.vec(n)?;
for _ in 0..n {
v.push(self.inline(Some(class))?);
}
Ok(Value::Array(v))
}
0x2d | 0x33 | 0x75 => Err(Error::Malformed("array of an unarrayable type")),
_ => {
let n = self.count(min)?;
let mut v = self.vec(n)?;
for _ in 0..n {
v.push(self.scalar(code, true, class)?);
}
Ok(Value::Array(v))
}
}
}
/// Inline object; `class` is given once for a whole array of them.
fn inline(&mut self, class: Option<Tag>) -> Result<Value, Error> {
match self.c.u8()? {
0 => return Ok(Value::Obj(None)),
1 => {}
_ => return Err(Error::Malformed("object marker")),
}
let class = match class {
Some(c) => c,
None => {
let c = Tag(self.c.u32()?);
self.c.u16()?;
c
}
};
let fields = self.fields(true, class)?;
let id = self.push(Object::new(class, Kind::Inline, vec![class], fields))?;
Ok(Value::Obj(Some(id)))
}
fn shared(&mut self) -> Result<Value, Error> {
let flag = self.c.u8()?;
if flag == 0 {
return Ok(Value::Obj(None));
}
let key = self.c.u32()?;
match flag {
2 => return self.shared.get(&key).map(|id| Value::Obj(Some(*id))).ok_or(Error::Malformed("reference to an undefined object")),
1 => {}
_ => return Err(Error::Malformed("object marker")),
}
if self.shared.contains_key(&key) {
return Err(Error::Malformed("object defined twice"));
}
// Register before the fields: an object's own fields may refer back to it.
let id = self.push(Object::new(Tag(0), Kind::Shared, Vec::new(), Vec::new()))?;
self.shared.insert(key, id);
let mut chain = Vec::new();
let mut fields = Vec::new();
loop {
match self.c.u8()? {
0 => {
let tag = Tag(self.c.u32()?);
self.c.u16()?;
chain.push(tag);
fields.extend(self.fields(true, tag)?);
}
1 => {
chain.push(Tag(self.c.u32()?));
break;
}
2 => break,
_ => return Err(Error::Malformed("object class chain")),
}
if chain.len() > 64 {
return Err(Error::Limit("object class chain too long"));
}
}
let class = chain.first().copied().unwrap_or(Tag(0));
fields.extend(self.fields(true, class)?);
if let Some(o) = self.objects.get_mut(id) {
*o = Object::new(class, Kind::Shared, chain, fields);
}
Ok(Value::Obj(Some(id)))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lookups_in_a_large_object_find_the_first_and_last_fields_of_a_tag() {
let tag = |i: u32| Tag(0x4100_0000 + i);
let mut fields: Vec<(Tag, Value)> = (0..1000).map(|i| (tag(i % 300), Value::UInt(u64::from(i)))).collect();
fields.push((tag(7), Value::Str("last".into())));
let o = Object::new(Tag::of(b"Grup"), Kind::Shared, Vec::new(), fields.clone());
assert!(!o.index.is_empty());
// The index has one entry per distinct tag, not per field.
assert_eq!(o.index.len(), 300);
for t in 0..300 {
let first = fields.iter().find(|(k, _)| *k == tag(t)).map(|(_, v)| v);
let last = fields.iter().rev().find(|(k, _)| *k == tag(t)).map(|(_, v)| v);
assert_eq!(o.get(tag(t)), first);
assert_eq!(o.get_last(tag(t)), last);
}
assert_eq!(o.get(tag(300)), None);
assert_eq!(o.get_last(tag(7)), Some(&Value::Str("last".into())));
let small = Object::new(Tag::of(b"Grup"), Kind::Shared, Vec::new(), vec![(tag(1), Value::Bool(true)), (tag(1), Value::Bool(false))]);
assert!(small.index.is_empty());
assert_eq!(small.get(tag(1)), Some(&Value::Bool(true)));
assert_eq!(small.get_last(tag(1)), Some(&Value::Bool(false)));
}
}
-259
View File
@@ -1,259 +0,0 @@
//! Synthetic containers for tests and fuzz seeds (cargo feature `synth`).
//!
//! These builders write the layout the reader accepts, so tests can describe documents in a few
//! lines. They are **not** an Affinity exporter: nothing written here has been opened in Affinity.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use crate::stream::Tag;
/// A field value to encode.
#[derive(Debug, Clone)]
pub enum F {
U8(u8),
U32(u32),
I32(i32),
F32(f32),
F64(f64),
Bool(bool),
Enum(u16, u16),
Str(String),
/// f64 vector of 2..6 values (points, rectangles, transforms).
F64s(Vec<f64>),
/// Fixed-size struct (colour components), 1..64 bytes.
Struct(Vec<u8>),
/// Array of fixed-size records, all `size` bytes.
Records(u16, Vec<Vec<u8>>),
U8s(Vec<u8>),
Entry(String),
/// Shared object: `Def` defines id with class chain and fields, `Ref` links back.
Def(u32, Vec<Tag>, Vec<(Tag, F)>),
Ref(u32),
Null,
/// Inline object.
Obj(Tag, Vec<(Tag, F)>),
/// Positional (untagged) object.
Pos(Vec<F>),
/// Array of shared objects / links.
Shared(Vec<F>),
/// Array of inline objects of one class.
Objs(Tag, Vec<Vec<(Tag, F)>>),
/// Raw bytes appended verbatim (malformed-input tests).
Raw(Vec<u8>),
}
pub fn tag(s: &[u8; 4]) -> Tag {
Tag::of(s)
}
fn put_tag(out: &mut Vec<u8>, t: Tag) {
out.extend(t.0.to_le_bytes());
}
fn code(f: &F) -> u8 {
match f {
F::U8(_) => 0x01,
F::U32(_) => 0x03,
F::I32(_) => 0x07,
F::F32(_) => 0x09,
F::F64(_) => 0x0a,
F::Bool(_) => 0x29,
F::Enum(..) => 0x2a,
F::Str(_) => 0x2b,
F::F64s(v) => 0x22 + v.len() as u8,
F::Struct(b) => 0x34 + b.len() as u8,
F::Records(..) => 0x80 | 0x2c,
F::U8s(_) => 0x80 | 0x01,
F::Entry(_) => 0x33,
F::Def(..) | F::Ref(_) => 0x31,
F::Null => 0x31,
F::Obj(..) => 0x32,
F::Pos(_) => 0x30,
F::Shared(_) => 0x80 | 0x31,
F::Objs(..) => 0x80 | 0x32,
F::Raw(_) => 0,
}
}
fn payload(out: &mut Vec<u8>, f: &F) {
match f {
F::U8(v) => out.push(*v),
F::U32(v) => out.extend(v.to_le_bytes()),
F::I32(v) => out.extend(v.to_le_bytes()),
F::F32(v) => out.extend(v.to_le_bytes()),
F::F64(v) => out.extend(v.to_le_bytes()),
F::Bool(v) => out.push(u8::from(*v)),
F::Enum(id, ver) => {
out.extend(id.to_le_bytes());
out.extend(ver.to_le_bytes());
}
F::Str(s) => {
out.extend((s.len() as u32).to_le_bytes());
out.extend(s.as_bytes());
}
F::F64s(v) => v.iter().for_each(|x| out.extend(x.to_le_bytes())),
F::Struct(b) => out.extend(b),
F::Records(size, recs) => {
out.extend((recs.len() as u32).to_le_bytes());
out.extend(size.to_le_bytes());
recs.iter().for_each(|r| out.extend(r));
}
F::U8s(v) => {
out.extend((v.len() as u32).to_le_bytes());
out.extend(v);
}
F::Entry(name) => {
put_tag(out, tag(b"Data"));
out.extend((name.len() as u32).to_le_bytes());
out.extend(name.as_bytes());
}
F::Def(id, chain, fields) => {
out.push(1);
out.extend(id.to_le_bytes());
// Class chain: each level but the last carries no fields here; the last ends the chain.
let (last, rest) = chain.split_last().expect("class chain");
for t in rest {
out.push(0);
put_tag(out, *t);
out.extend(0u16.to_le_bytes());
out.push(0);
}
out.push(1);
put_tag(out, *last);
fields_into(out, fields, true);
}
F::Ref(id) => {
out.push(2);
out.extend(id.to_le_bytes());
}
F::Null => out.push(0),
F::Obj(class, fields) => {
out.push(1);
put_tag(out, *class);
out.extend(0u16.to_le_bytes());
fields_into(out, fields, true);
}
F::Pos(items) => {
for f in items {
out.push(code(f));
payload(out, f);
}
out.push(0);
}
F::Shared(items) => {
out.extend((items.len() as u32).to_le_bytes());
items.iter().for_each(|f| payload(out, f));
}
F::Objs(class, items) => {
out.extend((items.len() as u32).to_le_bytes());
put_tag(out, *class);
out.extend(0u16.to_le_bytes());
for fields in items {
out.push(1);
fields_into(out, fields, true);
}
}
F::Raw(b) => out.extend(b),
}
}
fn fields_into(out: &mut Vec<u8>, fields: &[(Tag, F)], tagged: bool) {
for (t, f) in fields {
if let F::Raw(b) = f {
out.extend(b);
continue;
}
out.push(code(f));
if tagged {
put_tag(out, *t);
}
payload(out, f);
}
out.push(0);
}
/// A `doc.dat` stream with root class `Pers`.
pub fn stream(root: &[(Tag, F)]) -> Vec<u8> {
let mut out = b"\x00\xffKS".to_vec();
out.extend(2u16.to_le_bytes());
put_tag(&mut out, tag(b"Pers"));
out.extend(0u16.to_le_bytes());
out.extend(30u32.to_le_bytes());
fields_into(&mut out, root, true);
out
}
/// How an entry is stored.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Method {
Stored,
Zlib,
Zstd,
}
/// A version-12 container with one `#FT4` table holding `entries`, then an optional thumbnail record.
pub fn container(entries: &[(&str, &[u8], Method)], thumbnail_png: Option<&[u8]>) -> Vec<u8> {
let mut out = vec![0u8; 72];
out[..4].copy_from_slice(crate::container::MAGIC);
out[4..6].copy_from_slice(&12u16.to_le_bytes());
out[6..8].copy_from_slice(&4u16.to_le_bytes());
out[8..12].copy_from_slice(b"nsrP");
out[12..16].copy_from_slice(b"#Inf");
out[64..68].copy_from_slice(b"Prot");
let mut records = Vec::new();
for (i, (name, data, method)) in entries.iter().enumerate() {
let packed = match method {
Method::Stored => data.to_vec(),
Method::Zlib => {
use std::io::Write;
let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast());
e.write_all(data).unwrap();
e.finish().unwrap()
}
Method::Zstd => ruzstd::encoding::compress_to_vec(*data, ruzstd::encoding::CompressionLevel::Fastest),
};
let offset = out.len() as u64;
out.extend(b"#Fil");
out.extend(&packed);
let mut r = Vec::new();
r.extend((i as u32 + 1).to_le_bytes());
r.push(0);
r.extend(offset.to_le_bytes());
r.extend((data.len() as u64).to_le_bytes());
r.extend((packed.len() as u64).to_le_bytes());
r.extend(crc32fast::hash(data).to_le_bytes());
r.push(match method {
Method::Stored => 0,
Method::Zlib => 1,
Method::Zstd => 2,
});
r.extend(0u32.to_le_bytes());
r.extend(0u32.to_le_bytes());
r.extend((name.len() as u16).to_le_bytes());
r.extend(name.as_bytes());
records.push(r);
}
let fat = out.len() as u64;
out.extend(b"#FT4");
out.extend(0u64.to_le_bytes());
out.extend(1_700_000_000u64.to_le_bytes());
out.extend([0u8; 24]);
out.extend((records.len() as u32).to_le_bytes());
out.extend([0u8; 8]);
out.extend(0u16.to_le_bytes());
out.push(0);
records.iter().for_each(|r| out.extend(r));
let thumb = out.len() as u64;
out[16..24].copy_from_slice(&fat.to_le_bytes());
if let Some(png) = thumbnail_png {
out[24..32].copy_from_slice(&thumb.to_le_bytes());
out.extend(b"\xff\xff\xff\xffThmb");
out.extend(1u32.to_le_bytes());
out.extend((png.len() as u32 + 13).to_le_bytes());
out.extend(29u32.to_le_bytes());
out.extend(0u32.to_le_bytes());
out.extend((png.len() as u32).to_le_bytes());
out.push(1);
out.extend(png);
}
out
}
-408
View File
@@ -1,408 +0,0 @@
//! Unit tests on synthetic inputs; `tests/it/real_files.rs` covers public Affinity documents.
mod preview {
use crate::container::header;
use crate::*;
use proptest::prelude::*;
fn fixture() -> Vec<u8> {
let mut png = b"\x89PNG\r\n\x1a\n".to_vec();
png.extend(13u32.to_be_bytes());
png.extend(b"IHDR");
png.extend(2u32.to_be_bytes());
png.extend(1u32.to_be_bytes());
png.extend([8, 6, 0, 0, 0]);
png.extend(crc32fast::hash(&png[12..]).to_be_bytes());
png.extend([0; 4]);
png.extend(b"IDAT");
png.extend(crc32fast::hash(b"IDAT").to_be_bytes());
png.extend([0; 4]);
png.extend(b"IEND");
png.extend(crc32fast::hash(b"IEND").to_be_bytes());
let mut b = vec![0; 72];
b[..4].copy_from_slice(MAGIC);
b[4..6].copy_from_slice(&12u16.to_le_bytes());
b[8..12].copy_from_slice(b"nsrP");
b[12..16].copy_from_slice(b"#Inf");
b[24..32].copy_from_slice(&72u64.to_le_bytes());
b[64..68].copy_from_slice(b"Prot");
b.extend(b"\xff\xff\xff\xffThmb");
b.extend(1u32.to_le_bytes());
b.extend((png.len() as u32 + 13).to_le_bytes());
b.extend(29u32.to_le_bytes());
b.extend(0u32.to_le_bytes());
b.extend((png.len() as u32).to_le_bytes());
b.push(1);
b.extend(png);
b
}
#[test]
fn indexed_preview_and_header() {
let b = fixture();
assert_eq!(header(&b).unwrap().version, 12);
let p = preview(&b).unwrap();
assert_eq!((p.width, p.height), (2, 1));
assert_eq!(p.png, &b[101..]);
}
#[test]
fn every_truncation_is_rejected() {
let b = fixture();
for end in 0..b.len() {
assert!(preview(&b[..end]).is_err(), "{end}");
}
}
#[test]
fn rejects_assets_versions_offsets_lengths_and_bombs() {
let b = fixture();
for (offset, value) in [
(4, 13u64.to_le_bytes().to_vec()),
(8, b"urBR".to_vec()),
(24, u64::MAX.to_le_bytes().to_vec()),
(24, 1u64.to_le_bytes().to_vec()),
(24, 0u64.to_le_bytes().to_vec()),
(84, u32::MAX.to_le_bytes().to_vec()),
(88, 30u32.to_le_bytes().to_vec()),
(96, u32::MAX.to_le_bytes().to_vec()),
(117, 5000u32.to_be_bytes().to_vec()),
] {
let mut broken = b.clone();
broken[offset..offset + value.len()].copy_from_slice(&value);
assert!(preview(&broken).is_err(), "offset {offset}");
}
// Versions 8 to 11 store the same record (checked on public Affinity 1 and 2 files).
for version in 8..12 {
let mut legacy = b.clone();
legacy[4..6].copy_from_slice(&(version as u16).to_le_bytes());
assert!(preview(&legacy).is_ok());
}
// Flags 4 and 8 occur in real files; the low two bits mark other container variants.
for (flags, ok) in [(4u16, true), (8, true), (1, false), (2, false)] {
let mut variant = b.clone();
variant[6..8].copy_from_slice(&flags.to_le_bytes());
assert_eq!(preview(&variant).is_ok(), ok, "flags {flags}");
}
let mut old = b.clone();
old[4..6].copy_from_slice(&7u16.to_le_bytes());
assert!(matches!(preview(&old), Err(Error::Unsupported(_))));
}
#[test]
fn an_unindexed_png_or_resource_is_never_used() {
let mut b = fixture();
b[24..32].copy_from_slice(&0u64.to_le_bytes());
assert!(preview(&b).is_err());
b[24..32].copy_from_slice(&72u64.to_le_bytes());
b[76..80].copy_from_slice(b"Meta");
assert!(preview(&b).is_err());
}
#[test]
fn every_chunk_checksum_including_the_end_is_checked() {
let b = fixture();
for at in [130, b.len() - 1] {
let mut damaged = b.clone();
damaged[at] ^= 1;
assert_eq!(preview(&damaged).unwrap_err(), Error::Malformed("PNG chunk checksum"));
}
let mut ancillary = vec![0; 4];
ancillary.extend(b"tEXt");
ancillary.extend(crc32fast::hash(b"tEXt").to_be_bytes());
let mut with_ancillary = b.clone();
let at = b.len() - 12;
with_ancillary.splice(at..at, ancillary);
let png_length = (with_ancillary.len() - 101) as u32;
with_ancillary[84..88].copy_from_slice(&(png_length + 13).to_le_bytes());
with_ancillary[96..100].copy_from_slice(&png_length.to_le_bytes());
assert!(preview(&with_ancillary).is_ok());
with_ancillary[at + 8] ^= 1;
assert_eq!(preview(&with_ancillary).unwrap_err(), Error::Malformed("PNG chunk checksum"));
}
#[test]
fn compressed_metadata_and_animation_are_not_preview_support() {
for kind in [b"zTXt", b"iTXt", b"iCCP", b"acTL", b"fcTL", b"fdAT"] {
let mut extra = vec![0; 4];
extra.extend(kind);
extra.extend(crc32fast::hash(kind).to_be_bytes());
let mut b = fixture();
let at = b.len() - 12;
b.splice(at..at, extra);
let length = (b.len() - 101) as u32;
b[84..88].copy_from_slice(&(length + 13).to_le_bytes());
b[96..100].copy_from_slice(&length.to_le_bytes());
assert!(matches!(preview(&b), Err(Error::Unsupported(_))));
}
}
#[test]
fn critical_chunks_follow_a_single_complete_png_layout() {
fn with_chunks(kinds: &[&[u8; 4]]) -> Vec<u8> {
let original = fixture();
let mut b = original[..134].to_vec(); // File, record and complete first IHDR.
for kind in kinds {
b.extend([0; 4]);
b.extend(*kind);
b.extend(crc32fast::hash(*kind).to_be_bytes());
}
let length = (b.len() - 101) as u32;
b[84..88].copy_from_slice(&(length + 13).to_le_bytes());
b[96..100].copy_from_slice(&length.to_le_bytes());
b
}
assert!(preview(&with_chunks(&[b"IDAT", b"IDAT", b"IEND"])).is_ok());
// PNG readers must not reject an otherwise valid unknown ancillary chunk just
// because its reserved (third-letter) bit is set; future versions can define it.
assert!(preview(&with_chunks(&[b"abct", b"IDAT", b"IEND"])).is_ok());
for kinds in [
vec![b"IEND"],
vec![b"IDAT", b"IHDR", b"IEND"],
vec![b"PLTE", b"PLTE", b"IDAT", b"IEND"],
vec![b"IDAT", b"PLTE", b"IEND"],
vec![b"IDAT", b"tEXt", b"IDAT", b"IEND"],
vec![b"ABCD", b"IDAT", b"IEND"],
vec![b"IDAT", b"IEND", b"tEXt"],
] {
assert!(preview(&with_chunks(&kinds)).is_err(), "{kinds:?}");
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn arbitrary_input_never_panics(b in prop::collection::vec(any::<u8>(), 0..2048)) {
let _ = header(&b);
let _ = preview(&b);
}
#[test]
fn mutations_never_panic(edits in prop::collection::vec((0usize..200, any::<u8>()), 0..32)) {
let mut b = fixture();
for (i, value) in edits { if let Some(v) = b.get_mut(i) { *v = value; } }
let _ = preview(&b);
}
}
}
mod archive {
use crate::Error;
use crate::container::{Archive, Limits};
use crate::stream::{self, Kind, Tag, Value};
use crate::synth::{self, F, Method, tag};
use proptest::prelude::*;
fn doc() -> Vec<u8> {
synth::stream(&[
(tag(b"Desc"), F::Str("Document".into())),
(tag(b"Visi"), F::Bool(true)),
(tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, 5.0, 0.0, 1.0, -2.0])),
(tag(b"Colr"), F::Def(7, vec![tag(b"RGBA")], vec![(tag(b"_col"), F::Struct([0u8; 16].to_vec()))])),
(tag(b"Same"), F::Ref(7)),
(tag(b"Null"), F::Null),
(tag(b"Kids"), F::Shared(vec![F::Def(8, vec![tag(b"Grup"), tag(b"Node")], vec![(tag(b"Desc"), F::Str("g".into()))]), F::Ref(8)])),
(
tag(b"Crvs"),
F::Obj(tag(b"PCvD"), vec![(tag(b"Data"), F::Pos(vec![F::U8(0), F::U32(1), F::Bool(true), F::Records(18, vec![vec![0; 18]; 4])]))]),
),
(tag(b"Enum"), F::Enum(2, 1)),
(tag(b"Tile"), F::Entry("d/3".into())),
(tag(b"Stat"), F::U8s(vec![4, 0, 2])),
])
}
#[test]
fn every_method_round_trips_with_checksums() {
let d = doc();
for method in [Method::Stored, Method::Zlib, Method::Zstd] {
let file = synth::container(&[("doc.dat", &d, method), ("d/3", b"tile", method)], None);
let mut a = Archive::open(&file, Limits::default()).unwrap();
assert_eq!(a.header.version, 12);
assert_eq!(a.read("doc.dat").unwrap(), d);
assert_eq!(a.read("d/3").unwrap(), b"tile");
assert_eq!(a.extracted(), d.len() + 4);
assert!(a.read("missing").is_err());
}
}
#[test]
fn the_stream_parses_into_shared_objects_and_links() {
let s = stream::parse(&doc()).unwrap();
let root = &s.objects[s.root];
assert_eq!(root.class, Tag::of(b"Pers"));
assert_eq!(root.get(Tag::of(b"Desc")), Some(&Value::Str("Document".into())));
assert_eq!(root.get(Tag::of(b"Xfrm")), Some(&Value::Floats(vec![1.0, 0.0, 5.0, 0.0, 1.0, -2.0])));
let (Some(Value::Obj(Some(a))), Some(Value::Obj(Some(b)))) = (root.get(Tag::of(b"Colr")), root.get(Tag::of(b"Same"))) else { panic!() };
assert_eq!(a, b);
assert_eq!(s.objects[*a].class, Tag::of(b"RGBA"));
assert_eq!(s.objects[*a].kind, Kind::Shared);
let Some(Value::Array(kids)) = root.get(Tag::of(b"Kids")) else { panic!() };
assert_eq!(kids[0], kids[1]);
let Value::Obj(Some(g)) = kids[0] else { panic!() };
assert_eq!(s.objects[g].chain, vec![Tag::of(b"Grup"), Tag::of(b"Node")]);
assert_eq!(root.get(Tag::of(b"Enum")), Some(&Value::Enum { id: 2, version: 1 }));
assert_eq!(root.get(Tag::of(b"Tile")), Some(&Value::Entry("d/3".into())));
assert_eq!(root.get(Tag::of(b"Null")), Some(&Value::Obj(None)));
}
#[test]
fn checksums_sizes_and_budgets_are_enforced() {
let d = doc();
let file = synth::container(&[("doc.dat", &d, Method::Zstd)], None);
// Flip a byte inside the compressed payload, past the "#Fil" tag at offset 72.
let mut broken = file.clone();
broken[90] ^= 0x55;
let mut a = Archive::open(&broken, Limits::default()).unwrap();
assert!(a.read("doc.dat").is_err());
let mut a = Archive::open(&file, Limits { max_entry: d.len() - 1, max_total: usize::MAX }).unwrap();
assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_))));
let mut a = Archive::open(&file, Limits { max_entry: usize::MAX, max_total: d.len() + 1 }).unwrap();
a.read("doc.dat").unwrap();
assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_))));
}
#[test]
fn an_entry_that_fails_its_checksum_is_charged_once_and_not_decompressed_again() {
let d = doc();
let file = synth::container(&[("doc.dat", &d, Method::Zstd)], None);
let mut broken = file.clone();
broken[90] ^= 0x55;
let mut a = Archive::open(&broken, Limits::default()).unwrap();
let first = a.read("doc.dat").unwrap_err();
// The work was done, so it counts against the import even though it failed.
assert_eq!(a.extracted(), d.len());
assert_eq!(a.read("doc.dat").unwrap_err(), first);
assert_eq!(a.extracted(), d.len());
// A budget that only covers one attempt is not exceeded by asking again.
let mut a = Archive::open(&broken, Limits { max_entry: usize::MAX, max_total: d.len() }).unwrap();
assert!(a.read("doc.dat").is_err());
assert_eq!(a.read("doc.dat").unwrap_err(), first);
}
#[test]
fn the_table_index_has_a_budget_for_records_and_names() {
let d = doc();
let long = "n".repeat(300);
let file = synth::container(&[("doc.dat", &d, Method::Stored), (&long, b"tile", Method::Stored)], None);
Archive::open_within(&file, Limits::default(), 2, 307).unwrap();
assert_eq!(Archive::open_within(&file, Limits::default(), 1, 1 << 20).unwrap_err(), Error::Limit("too many archive entries"));
assert_eq!(Archive::open_within(&file, Limits::default(), 2, 306).unwrap_err(), Error::Limit("archive entry names too long"));
}
// Regression test for #1710: ruzstd 0.9.0 let a block decompress to more than its frame's
// window allows (RUSTSEC-2026-0344), so a crafted entry made the decoder produce far more than
// the format permits for a block. 0.9.1 refuses it.
#[test]
fn a_zstd_block_larger_than_its_frames_window_is_refused() {
const LEN: u32 = 100_000;
let plain = vec![0x61u8; LEN as usize];
let file = synth::container(&[("doc.dat", &plain, Method::Zstd)], None);
// A Zstandard frame with a 1 KiB window (descriptor byte 0, window byte 0) holding one RLE
// block, the last, that repeats a byte LEN times: block header = last | RLE << 1 | LEN << 3.
let mut frame = vec![0x28, 0xB5, 0x2F, 0xFD, 0x00, 0x00];
frame.extend((1 | (1 << 1) | (LEN << 3)).to_le_bytes().get(..3).unwrap());
frame.push(0x61);
// Swap it in for the entry's compressed bytes (they follow the "#Fil" tag at offset 72).
let fat = u64::from_le_bytes(file[16..24].try_into().unwrap()) as usize;
let old_len = u64::from_le_bytes(file[fat + 59 + 21..fat + 59 + 29].try_into().unwrap()) as usize;
let mut swapped = file[..76].to_vec();
swapped.extend(&frame);
swapped.extend(&file[76 + old_len..]);
let new_fat = fat - old_len + frame.len();
swapped[16..24].copy_from_slice(&(new_fat as u64).to_le_bytes());
swapped[new_fat + 59 + 21..new_fat + 59 + 29].copy_from_slice(&(frame.len() as u64).to_le_bytes());
let mut a = Archive::open(&swapped, Limits::default()).unwrap();
assert!(a.read("doc.dat").is_err(), "a block of {LEN} bytes was decompressed from a frame with a 1 KiB window");
}
#[test]
fn a_declared_size_smaller_than_the_data_is_rejected_not_truncated() {
let big = vec![7u8; 1 << 20];
let mut file = synth::container(&[("doc.dat", &big, Method::Zlib)], None);
// Rewrite the declared size in the table to 1 KiB: decoding must stop and fail.
let fat = u64::from_le_bytes(file[16..24].try_into().unwrap()) as usize;
let size_at = fat + 59 + 5 + 8;
file[size_at..size_at + 8].copy_from_slice(&1024u64.to_le_bytes());
let mut a = Archive::open(&file, Limits::default()).unwrap();
assert_eq!(a.read("doc.dat").unwrap_err(), Error::Malformed("archive entry size mismatch"));
}
#[test]
fn libraries_and_cyclic_tables_are_rejected() {
let d = doc();
let mut file = synth::container(&[("doc.dat", &d, Method::Stored)], None);
let mut library = file.clone();
library[8..12].copy_from_slice(b"rAsA");
assert!(matches!(Archive::open(&library, Limits::default()), Err(Error::Unsupported(_))));
let fat = u64::from_le_bytes(file[16..24].try_into().unwrap());
let at = fat as usize + 4;
file[at..at + 8].copy_from_slice(&fat.to_le_bytes());
assert_eq!(Archive::open(&file, Limits::default()).unwrap_err(), Error::Malformed("allocation tables form a cycle"));
}
#[test]
fn hostile_streams_fail_cleanly() {
let deep = (0..2000).fold(Vec::new(), |inner, _| vec![(tag(b"Chld"), F::Obj(tag(b"Grup"), inner))]);
assert_eq!(stream::parse(&synth::stream(&deep)).unwrap_err(), Error::Limit("objects nested too deeply"));
// An array claiming four billion elements in a few bytes.
let huge = synth::stream(&[(tag(b"Big_"), F::Raw(vec![0x81, b'_', b'g', b'i', b'B', 0xff, 0xff, 0xff, 0xff, 0]))]);
assert_eq!(stream::parse(&huge).unwrap_err(), Error::Malformed("array longer than its data"));
let forward = synth::stream(&[(tag(b"Link"), F::Ref(99))]);
assert!(stream::parse(&forward).is_err());
let twice = synth::stream(&[(tag(b"A___"), F::Def(1, vec![tag(b"RGBA")], vec![])), (tag(b"B___"), F::Def(1, vec![tag(b"RGBA")], vec![]))]);
assert!(stream::parse(&twice).is_err());
let unknown = synth::stream(&[(tag(b"X___"), F::Raw(vec![0x0b, 1, 2, 3, 4]))]);
assert!(matches!(stream::parse(&unknown), Err(Error::Unsupported(_))));
let d = doc();
for end in 0..d.len() {
assert!(stream::parse(&d[..end]).is_err(), "{end}");
}
}
#[test]
fn a_small_compressed_entry_cannot_make_the_stream_allocate_gigabytes() {
// Each decoded value takes about 40 bytes, and a bool array packs eight in a byte: 16 MiB
// of zero bits, a few KiB once compressed, would ask for 2^27 values (about 4 GiB). An
// array of one-byte values just past the budget fails the same way, before allocating.
let field = |code: u8, n: u32, data: usize| {
let mut f = vec![0x80 | code];
f.extend(tag(b"Big_").0.to_le_bytes());
f.extend(n.to_le_bytes());
f.resize(f.len() + data, 0);
F::Raw(f)
};
let bools = 1u32 << 27;
let bytes = u32::try_from(stream::MAX_VALUES).unwrap() + 1;
for f in [field(0x29, bools, (bools / 8) as usize), field(0x01, bytes, bytes as usize)] {
let d = synth::stream(&[(tag(b"Big_"), f)]);
let file = synth::container(&[("doc.dat", &d, Method::Zstd)], None);
assert!(file.len() < d.len() / 100, "{} of {}", file.len(), d.len());
let entry = Archive::open(&file, Limits::default()).unwrap().read("doc.dat").unwrap();
assert_eq!(stream::parse(&entry).unwrap_err(), Error::Limit("too many values in the document stream"));
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(512))]
#[test]
fn mutated_containers_and_streams_never_panic(edits in prop::collection::vec((0usize..4096, any::<u8>()), 1..24)) {
let d = doc();
for method in [Method::Stored, Method::Zlib, Method::Zstd] {
let mut file = synth::container(&[("doc.dat", &d, method)], None);
for (i, v) in &edits {
let n = file.len();
file[i % n] = *v;
}
if let Ok(bytes) = Archive::open(&file, Limits::default()).and_then(|mut a| a.read("doc.dat")) {
let _ = stream::parse(&bytes);
}
}
let mut s = d.clone();
for (i, v) in &edits {
let n = s.len();
s[i % n] = *v;
}
let _ = stream::parse(&s);
}
}
}
-564
View File
@@ -1,564 +0,0 @@
//! Artistic and frame text: the story's characters with their font, size, colour, tracking and
//! leading, the paragraph alignment and the first baseline. Layout (line breaks in frames, kerning,
//! OpenType features) is left to the importing application.
use crate::model::{Affine, Align, Paragraph, Point, Reader, Table, TableCell, Text, TextRun, VAlign, rect};
use crate::paint::{self, Color, Paint};
use crate::stream::{ObjId, Value};
/// Characters read per text node (stories are split into blocks; this caps hostile ones).
const MAX_CHARS: usize = 1 << 20;
/// 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 (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,
Align::Right => bounds.x1,
_ => bounds.x0,
};
if !artistic {
if s.objs(frame, b"ColW").len() > 1 || matches!(s.field(frame, b"ColW"), Some(Value::Array(a)) if a.len() > 1) {
r.warn("text frames with several columns (imported as one column)");
}
if s.class(id) == Some(crate::stream::Tag::of(b"TxtC")) {
r.warn("text frames with a curved outline (imported as rectangular frames)");
}
}
let text_transform = if artistic { world } else { text_scale(r, id).then(r.type_base) };
Some(Text {
runs,
align,
anchor: Point { x: anchor_x, y: first_baseline },
frame: if artistic { None } else { Some(bounds) },
transform: world,
text_transform,
})
}
/// Rows and columns a table may have.
const MAX_TABLE_LINES: usize = 4096;
/// Cells a table may have (rows × columns): each becomes a text object with its edge lines.
const MAX_TABLE_CELLS: usize = 1 << 16;
/// Work charged for each copy of a run's formatting a table cell keeps.
const TABLE_RUN_COST: usize = 16;
/// A table node (`TxtT`): the grid, the cells' text and the lines on the cell edges.
pub(crate) fn table(r: &mut Reader, id: ObjId, world: Affine) -> Option<Table> {
let s = r.s;
let story = s.obj(id, b"StSt")?;
let tabl = s.obj(id, b"TxtH").and_then(|h| s.obj(h, b"Tabl"))?;
let bounds = |pos: Option<ObjId>| -> Option<Vec<f64>> {
let Some(Value::Array(a)) = pos.and_then(|p| s.field(p, b"Posn")) else { return None };
let v: Vec<f64> = a.iter().take(MAX_TABLE_LINES + 2).map(float).collect::<Option<_>>()?;
(v.len() >= 2 && v.len() <= MAX_TABLE_LINES + 1 && v.windows(2).all(|w| w[1] >= w[0])).then_some(v)
};
let columns = bounds(s.obj(tabl, b"CPos"))?;
let rows = bounds(s.obj(tabl, b"RPos"))?;
let (ncols, nrows) = (columns.len() - 1, rows.len() - 1);
let count = ncols.checked_mul(nrows)?;
if count > MAX_TABLE_CELLS {
r.warn("tables with more than 65536 cells (left out)");
return None;
}
let Some(left) = r.budget.cells.checked_sub(count) else {
r.warn("tables beyond the cells one document may hold (left out)");
return None;
};
r.budget.cells = left;
let cell_objs = s.obj(tabl, b"Cell").map(|c| s.objs(c, b"Cell")).unwrap_or_default();
// Formatting that doesn't match the grid would land on the wrong cells (and merging them,
// move text between cells): only text then.
let cell_objs = if cell_objs.len() == count {
cell_objs
} else {
r.warn("tables whose cells don't match their grid (cell formatting left out)");
Vec::new()
};
let blank = || TableCell { runs: Vec::new(), inset: [0.0; 4], valign: VAlign::Top, merged_left: false };
let mut cells: Vec<TableCell> = (0..count).map(|_| blank()).collect();
for (i, (cell, obj)) in cells.iter_mut().zip(&cell_objs).enumerate() {
if let Some(inset) = s.floats::<4>(*obj, b"Inse").filter(|v| v.iter().all(|x| x.is_finite() && *x >= 0.0)) {
cell.inset = inset;
}
cell.valign = match s.enumeration(*obj, b"AliY").map(|(i, _)| i) {
Some(1) => VAlign::Center,
Some(2) => VAlign::Bottom,
_ => VAlign::Top,
};
// A cell merged into the one on its left; the first column has nothing to its left.
cell.merged_left = i % ncols != 0 && s.bool(*obj, b"BrLf") == Some(true);
}
// The story holds the text of every cell that isn't merged away, row by row, each ended by a
// cell break.
let owner_list: Vec<usize> = (0..count).filter(|i| cells.get(*i).is_some_and(|c| !c.merged_left)).collect();
let mut owners = owner_list.into_iter();
let mut target = owners.next();
let mut extra = false;
let (runs, _) = story_runs(r, story, world);
for run in runs {
let mut parts = run.text.split(CELL_BREAK).peekable();
while let Some(part) = parts.next() {
if !part.is_empty() {
// Each cell keeps its own copy of the run's formatting, so each copy is charged.
match target.and_then(|i| cells.get_mut(i)) {
Some(cell) if r.budget.spend(TABLE_RUN_COST).is_ok() => cell.runs.push(TextRun { text: part.to_string(), ..run.clone() }),
_ => extra = true,
}
}
if parts.peek().is_some() {
target = owners.next();
}
}
}
if extra {
r.warn("table text beyond its last cell (left out)");
}
let mut edges = |tag: &[u8; 4], expected: usize| -> Vec<Option<crate::paint::Stroke>> {
let objs = s.obj(tabl, tag).map(|e| s.objs(e, b"Edge")).unwrap_or_default();
if objs.len() != expected {
r.warn("tables whose lines don't match their grid (lines left out)");
return Vec::new();
}
objs.into_iter()
.map(|e| match (s.obj(e, b"Fill"), s.obj(e, b"Line")) {
(Some(pen), Some(line)) => paint::stroke(r, pen, line, world, false),
_ => None,
})
.collect()
};
let vertical = edges(b"CEdg", (ncols + 1) * nrows);
let horizontal = edges(b"REdg", ncols * (nrows + 1));
if let Some(format) = s.obj(tabl, b"TFmt") {
let filled =
s.objs(format, b"Clls").into_iter().filter_map(|c| s.obj(c, b"Fill")).any(|f| s.obj(f, b"FDeF").is_some_and(|d| !s.is(d, b"FilN")));
if filled {
r.warn("table cell fills (left out)");
}
}
let text_transform = text_scale(r, id).then(r.type_base);
Some(Table { columns, rows, cells, vertical, horizontal, transform: world, text_transform })
}
/// A story's character runs, each with its paragraph's format, and the first paragraph's
/// alignment. Table cells end with [`CELL_BREAK`].
pub(crate) fn story_runs(r: &mut Reader, story: ObjId, world: Affine) -> (Vec<TextRun>, Align) {
let s = r.s;
let mut runs = Vec::new();
let mut first_align = None;
let mut total = 0usize;
let mut placeholders = false;
let mut attribute_budget = Some(MAX_ATTRIBUTE_BYTES);
for block in s.objs(story, b"Blok") {
// Shared blocks and run lists are visited once per link, so every visit is charged, empty or not.
let paragraph_runs = s.obj(block, b"PAtt").map(|p| s.objs(p, b"Runs")).unwrap_or_default();
let attribute_runs = s.obj(block, b"GAtt").map(|g| s.objs(g, b"Runs")).unwrap_or_default();
if r.budget.spend(1 + paragraph_runs.len() + attribute_runs.len()).is_err() {
r.warn("text beyond the work one document may hold (truncated)");
break;
}
let Some(glyphs) = s.obj(block, b"Glyp") else {
r.warn("a text block without readable characters (left out)");
continue;
};
// Bound collection itself, including placeholders, before allocating the character buffer:
// by the story's limit and by what the document's other stories left.
let budget = (MAX_CHARS - total).min(r.budget.chars.saturating_sub(total));
let mut chars: Vec<char> = match s.str(glyphs, b"Utf8") {
Some(t) => t.chars().take(budget + 1).collect(),
None => {
// Inline objects (page numbers, anchors, cell ends) each take one index before
// the segment text.
let segments = s.objs(glyphs, b"Mixd");
if segments.is_empty() {
r.warn("a text block without readable characters (left out)");
continue;
}
let mut v = Vec::new();
for seg in segments {
for inline in s.objs(seg, b"Glys") {
if v.len() > budget {
break;
}
if s.is(inline, b"BrGl") && s.enumeration(inline, b"HdBk").is_some_and(|(kind, _)| kind == 4) {
v.push(CELL_BREAK);
} else {
v.push('\u{FFFC}');
placeholders = true;
}
}
v.extend(s.str(seg, b"Utf8").unwrap_or_default().chars().take((budget + 1).saturating_sub(v.len())));
if v.len() > budget {
break;
}
}
v
}
};
let truncated = chars.len() > budget;
if truncated {
chars.truncate(budget);
if budget < MAX_CHARS - total {
r.warn("text beyond the characters one document may hold (truncated)");
} else {
r.warn("text longer than a million characters (truncated)");
}
}
total += chars.len();
// Paragraph formats by end index; a run without an index covers the rest of the block.
let mut paragraphs: Vec<(usize, Paragraph)> = Vec::new();
let mut open: Option<Paragraph> = None;
for run in paragraph_runs {
let Some(attrs) = s.obj(run, b"Item") else { continue };
let a = paragraph(r, attrs);
first_align.get_or_insert(a.align);
match s.int(run, b"Indx").and_then(|v| usize::try_from(v).ok()) {
Some(end) => paragraphs.push((end, a)),
None => match open {
None => open = Some(a),
Some(first) if first != a => r.warn("mixed paragraph formats in one block (the first is used)"),
_ => {}
},
}
}
// Sorted by end (they are in order in every file), so both lookups below are binary
// searches: a block can have as many paragraphs and runs as characters.
paragraphs.sort_by_key(|(end, _)| *end);
let align_at = |i: usize| paragraphs.get(paragraphs.partition_point(|(end, _)| *end <= i)).map(|(_, a)| *a).or(open).unwrap_or_default();
let mut cuts: Vec<usize> = paragraphs.iter().map(|(end, _)| *end).filter(|e| *e < chars.len()).collect();
cuts.dedup();
let push = |r: &mut Reader, runs: &mut Vec<TextRun>, attrs: Option<ObjId>, from: usize, to: usize, budget: &mut Option<usize>| {
// Split at paragraph ends so every run has one paragraph format.
let inside = cuts.get(cuts.partition_point(|c| *c <= from)..cuts.partition_point(|c| *c < to)).unwrap_or_default();
let mut a = from;
for &c in inside.iter().chain(std::iter::once(&to)) {
push_run(r, runs, attrs, characters(&chars, a, c), world, budget, align_at(a));
a = c;
}
};
let mut start = 0usize;
for run in attribute_runs {
let Some(index) = s.int(run, b"Indx").and_then(|v| usize::try_from(v).ok()) else {
r.warn("invalid text attribute range (bounded; unassigned characters use defaults)");
continue;
};
let end = index.min(chars.len());
if index > chars.len() || end < start {
r.warn("invalid text attribute range (bounded; unassigned characters use defaults)");
}
if end <= start {
continue;
}
push(r, &mut runs, s.obj(run, b"Item"), start, end, &mut attribute_budget);
start = end;
}
if start < chars.len() {
push(r, &mut runs, None, start, chars.len(), &mut attribute_budget);
}
if truncated {
break;
}
}
r.budget.chars = r.budget.chars.saturating_sub(total);
if placeholders {
r.warn("text fields such as page numbers (left out of the text)");
}
(runs, first_align.unwrap_or(Align::Left))
}
fn characters(chars: &[char], start: usize, end: usize) -> String {
chars.get(start..end).unwrap_or_default().iter().filter(|c| **c != '\0' && **c != '\u{FFFC}').collect()
}
fn push_run(
r: &mut Reader,
runs: &mut Vec<TextRun>,
attrs: Option<ObjId>,
text: String,
world: Affine,
attribute_budget: &mut Option<usize>,
paragraph: Paragraph,
) {
if text.is_empty() {
return;
}
let run = match attrs {
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() {
r.warn("text without character attributes (characters preserved with default attributes)");
}
TextRun {
text,
postscript: String::new(),
family: String::new(),
weight: 400,
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,
}
}
};
runs.push(run);
}
fn slot<T: Copy>(v: Option<&Value>, i: usize, f: impl Fn(&Value) -> Option<T>) -> Option<T> {
match v? {
Value::Array(a) => a.get(i).and_then(f),
_ => None,
}
}
fn float(v: &Value) -> Option<f64> {
match v {
Value::Float(f) if f.is_finite() => Some(*f),
_ => None,
}
}
fn int(v: &Value) -> Option<i64> {
match v {
Value::Int(i) => Some(*i),
Value::UInt(u) => i64::try_from(*u).ok(),
_ => None,
}
}
fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine, attribute_budget: &mut Option<usize>) -> TextRun {
let s = r.s;
let doubles = s.field(attrs, b"Doub");
let ints = s.field(attrs, b"Ints");
let size = slot(doubles, 0, float).filter(|v| *v > 0.0 && *v < 1e6).unwrap_or(12.0 * r.dpi / 72.0);
let tracking = slot(doubles, 4, float).unwrap_or(0.0);
let leading = (slot(ints, 4, int) == Some(1)).then(|| slot(doubles, 13, float)).flatten().filter(|v| *v > 0.0);
let objects = match s.field(attrs, b"Objs") {
Some(Value::Array(a)) => a.iter().take(8).map(|v| if let Value::Obj(o) = v { *o } else { None }).collect(),
_ => Vec::new(),
};
let mut fill = match objects.first().copied().flatten().and_then(|f| paint::descriptor(r, f, world)) {
Some(fill) => fill,
None => {
r.warn("text without readable fill attributes (default black used)");
Paint::Solid(Color::Gray { v: 0.0, a: 1.0 })
}
};
// A single descriptor decode is bounded by paint::MAX_STOPS. Charge its owned stop data
// before keeping it; subsequent runs skip attribute decoding once the budget is exhausted.
if let Paint::Gradient(gradient) = &fill {
let bytes = gradient.stops.len().saturating_mul(std::mem::size_of::<paint::Stop>());
if !charge_attributes(r, attribute_budget, bytes) {
fill = Paint::Solid(Color::Gray { v: 0.0, a: 1.0 });
}
}
// Outlined characters: `Objs` 1 is the outline's paint, 2 its line style (as for shapes).
let mut stroke = match (attribute_budget.is_some(), objects.get(1).copied().flatten(), objects.get(2).copied().flatten()) {
(true, Some(pen), Some(style)) => paint::stroke(r, pen, style, Affine::IDENTITY, false),
_ => None,
};
// Its gradient stops and dashes are owned data too, charged like the fill's.
if let Some(st) = &stroke {
let stops = match &st.paint {
Paint::Gradient(g) => g.stops.len().saturating_mul(std::mem::size_of::<paint::Stop>()),
_ => 0,
};
let dash = st.dash.as_ref().map_or(0, |(d, _)| d.len().saturating_mul(std::mem::size_of::<f64>()));
if !charge_attributes(r, attribute_budget, stops.saturating_add(dash)) {
stroke = None;
}
}
// Underline (slot 0) and strikethrough (slot 1): 1 is a single line.
let mut decoration = |i: usize| match slot(ints, i, int) {
Some(1) => true,
Some(0) | None => false,
Some(_) => {
r.warn("text decorations of an unknown kind (left out)");
false
}
};
let (underline, strikethrough) = (decoration(0), decoration(1));
let h_scale = slot(doubles, 10, float).filter(|h| *h > 0.0 && *h < 100.0).unwrap_or(1.0);
// A resolved font is populated on the emoji run of the public Affinity 3 text-runs file
// (Courier New requested, Segoe UI Emoji stored in RFnt). Empty RFnt records are common.
// Source: https://github.com/SethRobinson/Patchy/tree/de84eab550758b30fa062e479f5778cce7693b73/test-fixtures/af
let requested = s.obj(attrs, b"DFnt");
let resolved =
s.obj(attrs, b"RFnt").filter(|f| !s.str(*f, b"Famy").unwrap_or_default().is_empty() || !s.str(*f, b"Post").unwrap_or_default().is_empty());
let font = resolved.or(requested);
if let Some(resolved) = resolved {
let differs = [b"Famy", b"Post"]
.iter()
.any(|tag| s.str(resolved, tag).unwrap_or_default() != requested.and_then(|f| s.str(f, tag)).unwrap_or_default());
if differs {
r.warn("text uses a stored fallback font (imported with that font)");
}
}
if font.and_then(|f| s.int(f, b"Widh")).is_some_and(|w| w != 5) {
r.warn("font width variants (exact face depends on the installed PostScript font)");
}
let postscript = font_name(r, attribute_budget, font.and_then(|f| s.str(f, b"Post")).unwrap_or_default());
let family = font_name(r, attribute_budget, font.and_then(|f| s.str(f, b"Famy")).unwrap_or_default());
let (features, all_caps) = font_features(r, objects.get(7).copied().flatten(), attribute_budget);
let weight = font.and_then(|f| s.int(f, b"Wegt")).unwrap_or(400);
let weight = if (1..=1000).contains(&weight) {
weight
} else {
r.warn("invalid font weight (imported as regular)");
400
};
TextRun {
text,
postscript,
family,
weight,
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(),
}
}
fn charge_attributes(r: &mut Reader, budget: &mut Option<usize>, bytes: usize) -> bool {
let Some(remaining) = *budget else { return false };
match remaining.checked_sub(bytes) {
Some(remaining) => {
*budget = Some(remaining);
true
}
None => {
*budget = None;
r.warn("text attribute budget exceeded (excess font names, features and gradients left out)");
false
}
}
}
/// Longest font name kept (OpenType names are far shorter); every run and paragraph carries it.
const MAX_FONT_NAME: usize = 256;
fn font_name(r: &mut Reader, budget: &mut Option<usize>, value: &str) -> String {
if value.len() > MAX_FONT_NAME {
r.warn("font names longer than 256 bytes (the default font is used)");
return String::new();
}
metadata_string(r, budget, value)
}
fn metadata_string(r: &mut Reader, budget: &mut Option<usize>, value: &str) -> String {
let bytes = value.len().saturating_add(std::mem::size_of::<String>());
if value.is_empty() || !charge_attributes(r, budget, bytes) { String::new() } else { value.to_owned() }
}
/// The public Affinity 3 caps fixture stores `smcp` and the private `CAP\x01` selector in
/// OtAt/Setn. Only the observed all-caps selector and boolean OpenType tags are mapped; other
/// values are reported, never guessed. Standard tag syntax: https://learn.microsoft.com/en-us/typography/opentype/spec/featuretags
fn font_features(r: &mut Reader, attrs: Option<ObjId>, attribute_budget: &mut Option<usize>) -> (Vec<String>, bool) {
let mut features = Vec::new();
let mut all_caps = false;
if attribute_budget.is_none() {
return (features, all_caps);
}
let s = r.s;
let Some(Value::Array(settings)) = attrs.and_then(|a| s.field(a, b"Setn")) else { return (features, all_caps) };
for (index, setting) in settings.iter().take(257).enumerate() {
if index == 256 {
r.warn("more than 256 text feature settings (truncated)");
break;
}
let Value::Obj(Some(setting)) = setting else {
r.warn("unreadable text feature settings (imported with defaults)");
continue;
};
let setting = *setting;
let tag = s.int(setting, b"Feat").and_then(|v| u32::try_from(v).ok()).map(u32::to_be_bytes);
let value = r.s.int(setting, b"Valu");
match (tag, value) {
(Some([b'C', b'A', b'P', 1]), Some(0 | 1)) => all_caps = value == Some(1),
(Some(tag), Some(value @ (0 | 1))) if tag.iter().all(u8::is_ascii_alphanumeric) => {
if !charge_attributes(r, attribute_budget, std::mem::size_of::<String>() + if value == 0 { 5 } else { 4 }) {
break;
}
let name = String::from_utf8_lossy(&tag);
features.push(if value == 0 { format!("-{name}") } else { name.into_owned() });
}
_ => r.warn("text feature selectors or non-boolean OpenType settings (imported with defaults)"),
}
}
(features, all_caps)
}
/// 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,
Some(0) | None => Align::Left,
Some(_) => {
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,
}
}
+81 -1
View File
@@ -20,6 +20,10 @@ fn shape(id: u32, class: &[u8; 4], parameters: Vec<(vectorcraft_affinity::stream
}
fn read(definitions: Vec<F>, top: u32) -> Document {
try_read(definitions, top).unwrap()
}
fn try_read(definitions: Vec<F>, top: u32) -> Result<Document, vectorcraft_affinity::Error> {
let data = synth::stream(&[
(tag(b"Defs"), F::Shared(definitions)),
(
@@ -39,7 +43,7 @@ fn read(definitions: Vec<F>, top: u32) -> Document {
),
]);
let bytes = synth::container(&[("doc.dat", &data, Method::Zlib)], None);
vectorcraft_affinity::read(&bytes, Limits::default()).unwrap()
vectorcraft_affinity::read(&bytes, Limits::default())
}
fn path(document: &Document) -> &Path {
@@ -200,3 +204,79 @@ fn unlocked_rectangle_corners_keep_their_own_radius_and_type() {
assert_eq!(sub.segments.len(), 8);
assert_eq!(sub.segments[3][2], p(5.0, 20.0));
}
/// The special shapes come in as their own outlines: one closed contour of finite points inside
/// (or, for a callout's tail, just below) the node's box, and none as the bounding ellipse.
#[test]
fn special_shapes_have_their_own_closed_outlines() {
for class in [b"ShCl", b"ShHt", b"ShCg", b"ShCR", b"ShCE", b"ShDA", b"ShDS", b"ShTr", b"ShCr", b"ShpD", b"ShSg"] {
let name = String::from_utf8_lossy(class).into_owned();
let document = read(vec![shape(100, class, vec![])], 100);
assert!(!document.warnings.iter().any(|w| w.contains("bounding ellipse")), "{name}: {:?}", document.warnings);
let (Kind::Shape { path, even_odd, .. }, cog) = (&document.spreads[0].nodes[0].kind, class == b"ShCg") else { panic!("{name}: no shape") };
// A cog is its teeth and its hole, filled even-odd.
assert_eq!((path.subpaths.len(), *even_odd), if cog { (2, true) } else { (1, false) }, "{name}");
for sub in &path.subpaths {
assert!(sub.closed && sub.segments.len() >= 2, "{name}");
for p in std::iter::once(sub.start).chain(sub.segments.iter().flatten().copied()) {
assert!(p.x.is_finite() && p.y.is_finite(), "{name}: {p:?}");
assert!((-5.0..=15.0).contains(&p.x) && (-10.0..=30.0).contains(&p.y), "{name}: {p:?}");
}
}
}
// A class the reader does not know is still its bounding ellipse, and said.
let document = read(vec![shape(100, b"ShZZ", vec![])], 100);
assert!(document.warnings.iter().any(|w| w.contains("unrecognized parametric shape")), "{:?}", document.warnings);
}
fn group(id: u32, children: Vec<F>) -> F {
F::Def(id, vec![tag(b"Grup")], vec![(tag(b"Chld"), F::Shared(children))])
}
/// A list's null slots yield no node, operand or segment, but each visit of a shared object scans
/// them all: they are charged to the document's work before the scan.
#[test]
fn null_slots_of_shared_lists_are_charged_for_every_visit() {
// A compound of a million null operands, placed 20 times: 20 million slots, over the 2^24.
let document = read(vec![compound(100, vec![F::Null; 1_000_000]), group(200, vec![F::Ref(100); 20])], 200);
assert!(document.warnings.iter().any(|w| w.contains("geometry work limit")), "{:?}", document.warnings);
let shapes = document.spreads[0].nodes[0].children.iter().filter(|n| matches!(n.kind, Kind::Shape { .. })).count();
assert_eq!(shapes, 16, "each placement costs a million slots of the 2^24");
// A group of a million null children, placed 20 times, fails the read like too many layers.
let result = try_read(vec![group(100, vec![F::Null; 1_000_000]), group(200, vec![F::Ref(100); 20])], 200);
assert!(matches!(result, Err(vectorcraft_affinity::Error::Limit(_))), "{:?}", result.map(|d| d.warnings));
// Within the work of one document the same lists are read, nulls skipped.
let document = read(vec![group(100, vec![F::Null; 1_000]), group(200, vec![F::Ref(100); 20])], 200);
assert_eq!((document.spreads[0].nodes[0].children.len(), document.warnings.len()), (20, 0));
}
fn boxed(class: &[u8; 4], b: Vec<f64>) -> F {
F::Def(100, vec![tag(b"ShpN")], vec![(tag(b"ShpB"), F::F64s(b)), (tag(b"Shpe"), F::Def(10_100, vec![tag(class)], vec![]))])
}
/// A callout rectangle (`ShCR`) whose box runs right to left (x0 > x1) panicked in `f64::clamp`
/// (min > max), found by the `stream` fuzz target. It reads, inside its box.
#[test]
fn callout_rectangle_with_a_reversed_box_reads_inside_it() {
for b in [vec![10.0_f64, 0.0, 0.0, 20.0], vec![6.8e38, 0.0, 10.0, 20.0]] {
let (lo, hi) = (b[0].min(b[2]), b[0].max(b[2]));
let document = read(vec![boxed(b"ShCR", b)], 100);
let sub = &path(&document).subpaths[0];
let points = std::iter::once(&sub.start).chain(sub.segments.iter().flatten());
assert!(points.into_iter().all(|q| q.x.is_finite() && q.y.is_finite() && (lo..=hi).contains(&q.x)), "{sub:?}");
}
}
/// A box with finite corners whose width overflows gave infinite and NaN outline points. Such a
/// shape imports as its box, with a warning.
#[test]
fn a_shape_too_large_to_compute_imports_as_its_box() {
for class in [b"ShCR", b"ShpE", b"ShCg"] {
let document = read(vec![boxed(class, vec![f64::MAX, 0.0, -f64::MAX, 20.0])], 100);
let sub = &path(&document).subpaths;
assert_eq!(sub.len(), 1, "{class:?}");
let corners: Vec<Point> = std::iter::once(sub[0].start).chain(sub[0].segments.iter().map(|s| s[2])).collect();
assert!(corners.iter().all(|p| p.x.abs() == f64::MAX && (p.y == 0.0 || p.y == 20.0)), "{class:?}: {corners:?}");
assert!(document.warnings.iter().any(|w| w.contains("too large to compute")), "{:?}", document.warnings);
}
}
@@ -19,7 +19,7 @@ patchy-lazy-placed.af dpi=72;spreads=1;model_crc32=8776a8a0;counts={"depth": 1,
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-2.af dpi=72;spreads=1;model_crc32=6f174318;counts={"depth": 1, "fills": 12, "gradients": 0, "nodes": 12, "segments": 168, "shapes": 12, "strokes": 0, "subpaths": 13};boards=[];fonts={};warnings=[]
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×)"]