mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-12 12:24:54 +00:00
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:
Generated
+12
-11
@@ -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]]
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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>>,
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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×)"]
|
||||
|
||||
Reference in New Issue
Block a user