diff --git a/ASSETS.md b/ASSETS.md
index 27bbcfc5e7..34d75235c6 100644
--- a/ASSETS.md
+++ b/ASSETS.md
@@ -160,6 +160,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `assets/icons/dc-fx.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-gamut.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-grad-conic.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
+| `assets/icons/dc-grad-diamond.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-grad-freeform.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-grad-linear.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-grad-radial.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
diff --git a/Cargo.lock b/Cargo.lock
index f6bebf7788..02e109b6b2 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -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]]
diff --git a/Cargo.toml b/Cargo.toml
index b733a0f3b9..29f43866c2 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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" }
diff --git a/assets/icons/dc-grad-diamond.svg b/assets/icons/dc-grad-diamond.svg
new file mode 100644
index 0000000000..2efeca27e9
--- /dev/null
+++ b/assets/icons/dc-grad-diamond.svg
@@ -0,0 +1 @@
+
diff --git a/crates/affinity/Cargo.toml b/crates/affinity/Cargo.toml
index c335c7b16c..798b0734f5 100644
--- a/crates/affinity/Cargo.toml
+++ b/crates/affinity/Cargo.toml
@@ -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 }
diff --git a/crates/affinity/README.md b/crates/affinity/README.md
index d0a641697d..38c096cbcc 100644
--- a/crates/affinity/README.md
+++ b/crates/affinity/README.md
@@ -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,
diff --git a/crates/affinity/src/access.rs b/crates/affinity/src/access.rs
deleted file mode 100644
index 0b60ed7dbd..0000000000
--- a/crates/affinity/src/access.rs
+++ /dev/null
@@ -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 {
- 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 {
- 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 {
- 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 {
- 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 {
- 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 {
- 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(&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- {
- self.object(id).into_iter().flat_map(|o| o.fields.iter().map(|(_, v)| v))
- }
-}
diff --git a/crates/affinity/src/container.rs b/crates/affinity/src/container.rs
deleted file mode 100644
index f26948e919..0000000000
--- a/crates/affinity/src/container.rs
+++ /dev/null
@@ -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 = 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,
- /// 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,
-}
-
-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(&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 {
- Ok(self.array::<1>()?[0])
- }
- pub(crate) fn u16(&mut self) -> Result {
- Ok(u16::from_le_bytes(self.array()?))
- }
- pub(crate) fn u32(&mut self) -> Result {
- Ok(u32::from_le_bytes(self.array()?))
- }
- pub(crate) fn u64(&mut self) -> Result {
- Ok(u64::from_le_bytes(self.array()?))
- }
-}
-
-fn offset(v: u64) -> Result {
- 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 {
- 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::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 {
- 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)> = HashMap::new();
- let mut names: HashMap = 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
- {
- 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, 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, 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, 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, Error> {
- read_bounded(flate2::read::ZlibDecoder::new(data), size)
-}
-
-fn unzstd(data: &[u8], size: usize) -> Result, 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;
- }
-}
diff --git a/crates/affinity/src/geometry.rs b/crates/affinity/src/geometry.rs
deleted file mode 100644
index bb1c45746b..0000000000
--- a/crates/affinity/src/geometry.rs
+++ /dev/null
@@ -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,
- 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 {
- 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> {
- 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 = (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; 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,
- out: Option,
- /// 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> {
- let mut out: Vec = 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 {
- 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 = 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> {
- 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:?}");
- }
- }
-}
diff --git a/crates/affinity/src/lib.rs b/crates/affinity/src/lib.rs
index 38d86a8990..e14c1439a7 100644
--- a/crates/affinity/src/lib.rs
+++ b/crates/affinity/src/lib.rs
@@ -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::*;
diff --git a/crates/affinity/src/model.rs b/crates/affinity/src/model.rs
deleted file mode 100644
index a6d18f339e..0000000000
--- a/crates/affinity/src/model.rs
+++ /dev/null
@@ -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,
-}
-
-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 {
- 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,
- 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,
- pub fill: Paint,
- /// Outlined characters: the outline's paint and width (node space).
- pub stroke: Option,
- /// 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,
- /// 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,
- 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,
- /// 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,
- /// 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,
- /// Row boundaries (one more than the rows), increasing, in node space.
- pub rows: Vec,
- /// Row by row.
- pub cells: Vec,
- /// 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