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>, - /// Horizontal edges boundary by boundary (one more than the rows), one per column (empty - /// as `vertical` can be). - pub horizontal: Vec>, - /// 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), - /// 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), - /// The original file a placed image was made from (PNG, JPEG…), as embedded. - Encoded(Vec), -} - -#[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, - strokes: Vec, - }, - /// 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, - strokes: Vec, - 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, - /// Pixel mask: grey levels (white shows the node, black hides it); outside it the node is hidden. - pub pixel_mask: Option, - /// Layer effects the importer can show or bake in (others are reported). - pub effects: Vec, - /// Bottom to top. - pub children: Vec, -} - -#[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, - /// Transparent background; otherwise the page is white. - pub transparent: bool, - /// Bottom to top. - pub nodes: Vec, -} - -#[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, - /// Master spreads, which [`Kind::MasterInstance`] nodes show on pages. - pub masters: Vec, - /// Application that last saved the file, as stored (`Affinity 3.0.2`), when present. - pub saved_by: Option, - /// Content that was not imported or was approximated, one line per kind, deduplicated. - pub warnings: Vec, - /// 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>, -} diff --git a/crates/affinity/src/model/bounds.rs b/crates/affinity/src/model/bounds.rs deleted file mode 100644 index 7ef9fff0b2..0000000000 --- a/crates/affinity/src/model/bounds.rs +++ /dev/null @@ -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 { - 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 { - 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) { - 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 { - fn visit(kind: &Kind, children: &[Node], depth: usize, out: &mut Option) -> 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 { - let mut out = None; - kind_points(kind, &mut |p| add_point(&mut out, p)); - out -} - -fn add_point(out: &mut Option, 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 { - if let Some(md) = s.obj(spread, b"SpMd") { - let v: Vec = - 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", - } -} diff --git a/crates/affinity/src/model/read.rs b/crates/affinity/src/model/read.rs deleted file mode 100644 index 80f4e0993b..0000000000 --- a/crates/affinity/src/model/read.rs +++ /dev/null @@ -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 { - 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 { - 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, - 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 { - 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, - active: HashSet, - /// 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>, - /// The master spreads, in the order of [`Document::masters`]. - master_ids: Vec, - /// 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) { - *self.warnings.entry(what.into()).or_default() += 1; - } - - /// The document. - fn document(&mut self) -> Result { - 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, 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, 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, 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 { - 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) -> Vec { - 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 { - 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) -> Result<(Option, Option), Error> { - let s = self.s; - let attached = s.objs(id, b"AdCh"); - let mut mask: Option = None; - let mut pixels: Option = 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> { - 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"))); - } -} diff --git a/crates/affinity/src/overlay.rs b/crates/affinity/src/overlay.rs deleted file mode 100644 index 80f54f68e9..0000000000 --- a/crates/affinity/src/overlay.rs +++ /dev/null @@ -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> { - 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, Vec) = 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> { - 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 { - 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 { - 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:?}"); - } -} diff --git a/crates/affinity/src/paint.rs b/crates/affinity/src/paint.rs deleted file mode 100644 index c7b2acbbd3..0000000000 --- a/crates/affinity/src/paint.rs +++ /dev/null @@ -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, - /// 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)>, - /// 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>, -} - -/// 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> { - 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_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 { - let s = r.s; - let class = s.class(id)?; - let col = || -> Option> { - 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 { 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 { - 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 { - 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 { - 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, Vec) { - 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 { - 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 = 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); - } -} diff --git a/crates/affinity/src/preview.rs b/crates/affinity/src/preview.rs deleted file mode 100644 index d3341eeb1c..0000000000 --- a/crates/affinity/src/preview.rs +++ /dev/null @@ -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 { - 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 { - 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, 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 }) -} diff --git a/crates/affinity/src/raster.rs b/crates/affinity/src/raster.rs deleted file mode 100644 index bb8c1c88b8..0000000000 --- a/crates/affinity/src/raster.rs +++ /dev/null @@ -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>, - source: Option<(usize, Vec)>, -} - -pub(crate) fn node_image(r: &mut Reader, id: ObjId, world: Affine) -> Result, 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) -> Result, 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 { - 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, 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, 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, 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, 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 = 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, 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, 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) { - 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 = 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 { - 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, 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, 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 { - 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 { - 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 { - 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 = (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 { - 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); - } -} diff --git a/crates/affinity/src/shapes.rs b/crates/affinity/src/shapes.rs deleted file mode 100644 index a2503539f4..0000000000 --- a/crates/affinity/src/shapes.rs +++ /dev/null @@ -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 { - 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) -> Option { - 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 { - 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> = 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 = 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); 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 { - 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) } -} diff --git a/crates/affinity/src/stream.rs b/crates/affinity/src/stream.rs deleted file mode 100644 index 9d31f35e70..0000000000 --- a/crates/affinity/src/stream.rs +++ /dev/null @@ -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), - Floats(Vec), - /// Fixed-size record (curve nodes, ids) or fixed-size struct (colour components). - Bytes(Vec), - Blob(Vec), - /// Name of another archive entry (raster tiles, embedded files). - Entry(String), - Flags(u64), - /// `None` is a null object. - Obj(Option), - Array(Vec), -} - -#[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, - /// 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, 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 { - 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, -} - -/// 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, - /// Shared ids, defined at first use and referenced backwards. - shared: std::collections::HashMap, - 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 { - 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 { - 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(&mut self, n: usize) -> Result, 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 { - 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, 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 { - 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 { - 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 { - 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 { - 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) -> Result { - 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 { - 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))); - } -} diff --git a/crates/affinity/src/synth.rs b/crates/affinity/src/synth.rs deleted file mode 100644 index 5b75ca0bff..0000000000 --- a/crates/affinity/src/synth.rs +++ /dev/null @@ -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), - /// Fixed-size struct (colour components), 1..64 bytes. - Struct(Vec), - /// Array of fixed-size records, all `size` bytes. - Records(u16, Vec>), - U8s(Vec), - Entry(String), - /// Shared object: `Def` defines id with class chain and fields, `Ref` links back. - Def(u32, Vec, Vec<(Tag, F)>), - Ref(u32), - Null, - /// Inline object. - Obj(Tag, Vec<(Tag, F)>), - /// Positional (untagged) object. - Pos(Vec), - /// Array of shared objects / links. - Shared(Vec), - /// Array of inline objects of one class. - Objs(Tag, Vec>), - /// Raw bytes appended verbatim (malformed-input tests). - Raw(Vec), -} - -pub fn tag(s: &[u8; 4]) -> Tag { - Tag::of(s) -} - -fn put_tag(out: &mut Vec, 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, 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, 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 { - 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 { - 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 -} diff --git a/crates/affinity/src/tests.rs b/crates/affinity/src/tests.rs deleted file mode 100644 index 75b6b7b146..0000000000 --- a/crates/affinity/src/tests.rs +++ /dev/null @@ -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 { - 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 { - 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::(), 0..2048)) { - let _ = header(&b); - let _ = preview(&b); - } - #[test] - fn mutations_never_panic(edits in prop::collection::vec((0usize..200, any::()), 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 { - 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::()), 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); - } - } -} diff --git a/crates/affinity/src/text.rs b/crates/affinity/src/text.rs deleted file mode 100644 index d2bb55ca0c..0000000000 --- a/crates/affinity/src/text.rs +++ /dev/null @@ -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 { - 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 { - 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| -> Option> { - let Some(Value::Array(a)) = pos.and_then(|p| s.field(p, b"Posn")) else { return None }; - let v: Vec = a.iter().take(MAX_TABLE_LINES + 2).map(float).collect::>()?; - (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 = (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 = (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> { - 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, 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 = 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 = 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 = paragraphs.iter().map(|(end, _)| *end).filter(|e| *e < chars.len()).collect(); - cuts.dedup(); - let push = |r: &mut Reader, runs: &mut Vec, attrs: Option, from: usize, to: usize, budget: &mut Option| { - // 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, - attrs: Option, - text: String, - world: Affine, - attribute_budget: &mut Option, - 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(v: Option<&Value>, i: usize, f: impl Fn(&Value) -> Option) -> Option { - match v? { - Value::Array(a) => a.get(i).and_then(f), - _ => None, - } -} - -fn float(v: &Value) -> Option { - match v { - Value::Float(f) if f.is_finite() => Some(*f), - _ => None, - } -} - -fn int(v: &Value) -> Option { - 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) -> 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::()); - 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::()), - _ => 0, - }; - let dash = st.dash.as_ref().map_or(0, |(d, _)| d.len().saturating_mul(std::mem::size_of::())); - 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, 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, 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, value: &str) -> String { - let bytes = value.len().saturating_add(std::mem::size_of::()); - 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, attribute_budget: &mut Option) -> (Vec, 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::() + 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, - } -} diff --git a/crates/affinity/tests/it/outline_limits.rs b/crates/affinity/tests/it/outline_limits.rs index 74151c192f..15e08eb6be 100644 --- a/crates/affinity/tests/it/outline_limits.rs +++ b/crates/affinity/tests/it/outline_limits.rs @@ -20,6 +20,10 @@ fn shape(id: u32, class: &[u8; 4], parameters: Vec<(vectorcraft_affinity::stream } fn read(definitions: Vec, top: u32) -> Document { + try_read(definitions, top).unwrap() +} + +fn try_read(definitions: Vec, top: u32) -> Result { let data = synth::stream(&[ (tag(b"Defs"), F::Shared(definitions)), ( @@ -39,7 +43,7 @@ fn read(definitions: Vec, 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::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) -> 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 = 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); + } +} diff --git a/crates/affinity/tests/support/affinity3-signatures.txt b/crates/affinity/tests/support/affinity3-signatures.txt index 13e8f0c53a..79ed6df5ec 100644 --- a/crates/affinity/tests/support/affinity3-signatures.txt +++ b/crates/affinity/tests/support/affinity3-signatures.txt @@ -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×)"] diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs index 18aa1bd189..c5803e3224 100644 --- a/crates/color/src/gradient.rs +++ b/crates/color/src/gradient.rs @@ -19,13 +19,18 @@ pub enum GradientKind { /// the page from the vector, with a seam where the last stop meets the first. A negative /// `aspect` mirrors the sweep (see [`GradientGeom::transform`]). Conic, + /// Diamond gradients: the stops run from the center (`start`) out to a square whose + /// half-diagonal is the vector (a diamond when the vector is level), stretched across the + /// vector by `aspect` (see [`GradientGeom::diamond_frame`]). + Diamond, } impl GradientKind { /// Every kind, in the order the Gradient panel lists them. - pub const ALL: [GradientKind; 4] = [GradientKind::Linear, GradientKind::Radial, GradientKind::Conic, GradientKind::Freeform]; - /// Parse a kind name (`linear`, `radial`, `conic`, `freeform`; any case). `angle` and - /// `conical` also mean conic. + pub const ALL: [GradientKind; 5] = + [GradientKind::Linear, GradientKind::Radial, GradientKind::Conic, GradientKind::Diamond, GradientKind::Freeform]; + /// Parse a kind name (`linear`, `radial`, `conic`, `diamond`, `freeform`; any case). `angle` + /// and `conical` also mean conic. pub fn parse(s: &str) -> Option { if s.eq_ignore_ascii_case("angle") || s.eq_ignore_ascii_case("conical") { return Some(GradientKind::Conic); @@ -38,12 +43,13 @@ impl GradientKind { GradientKind::Radial => "Radial", GradientKind::Freeform => "Freeform", GradientKind::Conic => "Conic", + GradientKind::Diamond => "Diamond", } } - /// Is it placed by a center (`start`) and an extent ellipse (radial and conic), rather than - /// by a line? + /// Is it placed by a center (`start`) and an extent around it (radial, conic and diamond), + /// rather than by a line? pub fn is_centered(self) -> bool { - matches!(self, GradientKind::Radial | GradientKind::Conic) + matches!(self, GradientKind::Radial | GradientKind::Conic | GradientKind::Diamond) } } @@ -668,13 +674,22 @@ fn one64() -> f64 { impl GradientGeom { /// Default placement for a bounding box: linear spans the box through the centre at `angle_deg`, /// radial and conic are centered with radius = half the larger dimension and their vector at - /// `angle_deg`. + /// `angle_deg`, and diamond is centered with its vector at `angle_deg`, its square's edge + /// running through the box's farthest corner (at 0 or 90 degrees, through all four). pub fn fit(kind: GradientKind, b: Rect, angle_deg: f64) -> Self { let c = b.center(); let a = angle_deg.to_radians(); let d = Vec2::new(a.cos(), -a.sin()); match kind { GradientKind::Radial | GradientKind::Conic => Self { start: c, end: c + d * (b.width().max(b.height()) / 2.0), aspect: 1.0, focal: None }, + GradientKind::Diamond => { + // The square through the box's farthest corner: the largest reach along the vector + // plus across it, at one corner. + let (w, h) = (b.width() / 2.0, b.height() / 2.0); + let level = |x: f64, y: f64| (x * d.x + y * d.y).abs() + (y * d.x - x * d.y).abs(); + let half = level(w, h).max(level(w, -h)); + Self { start: c, end: c + d * half, aspect: 1.0, focal: None } + } _ => { // Project the box corners onto the direction to cover the whole box. let half = (b.width() * d.x.abs() + b.height() * d.y.abs()) / 2.0; @@ -694,6 +709,10 @@ impl GradientGeom { /// other way: exact for moves, rotations, uniform scales and mirrors, and the nearest frame /// that keeps the seam under a shear or a non-uniform scale. A conic whose vector has no /// length, or whose aspect is 0, still has the sign of its aspect flipped by a mirror. + /// Diamond gradients keep their vector (a corner of the square) the same way, with the size + /// of the mapped extent across it as their aspect: exact for moves, rotations, scales along or + /// across the vector and mirrors, and the nearest square stretched across its vector under a + /// shear or another scale. pub fn transform(&mut self, a: Affine, kind: GradientKind) { // The focal point is a point like any other. self.focal = self.focal.map(|f| a * f); @@ -724,6 +743,14 @@ impl GradientGeom { }; return; } + if kind == GradientKind::Diamond { + let (lu, lw) = (lin(u), lin(perp(u) * self.aspect)); + self.start = start; + self.end = start + lu; + let aspect = (lw.dot(perp(lu)) / lu.hypot2()).abs(); + self.aspect = if lu.hypot2() > 1e-18 && aspect.is_finite() { aspect } else { 1.0 }; + return; + } if kind != GradientKind::Radial { let far = a * self.end; let nrm = perp(lin(perp(u))); @@ -768,6 +795,8 @@ impl GradientGeom { /// measured from the focal point (each level is the ellipse scaled about the line from the /// focal point to the centre). For conic gradients it is the turn from the vector in /// [`Self::conic_frame`], clockwise on the page (counterclockwise when mirrored), in 0..=1. + /// For diamond gradients it is the size of the square through `p`, as a fraction of the one + /// [`Self::diamond_frame`] gives (1 everywhere for a diamond without a frame). pub fn param_at(&self, kind: GradientKind, p: Point) -> f64 { if kind == GradientKind::Conic { let q = (self.conic_frame().inverse() * p).to_vec2(); @@ -775,6 +804,13 @@ impl GradientGeom { let t = if t < 0.0 { t + 1.0 } else { t }; return if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 }; } + if kind == GradientKind::Diamond { + // Without a frame the square has no inside: every point is past its edge. + let Some(m) = self.diamond_frame() else { return 1.0 }; + let q = (m.inverse() * p).to_vec2(); + let t = q.x.abs() + q.y.abs(); + return if t.is_nan() { 1.0 } else { t }; + } let u = self.end - self.start; if u.hypot2() < 1e-18 { return 0.0; @@ -835,32 +871,52 @@ impl GradientGeom { pub fn conic_wedges(&self, b: Rect, n: usize, overlap: bool) -> Vec<(Vec, f64)> { let n = n.clamp(1, MAX_CONIC_WEDGES); let frame = self.conic_frame(); - let to_unit = frame.inverse(); - let corners = [b.origin(), Point::new(b.x1, b.y0), Point::new(b.x1, b.y1), Point::new(b.x0, b.y1)]; - let reach = corners.iter().map(|&c| (to_unit * c).to_vec2().hypot()).fold(0.0f64, f64::max).max(1e-6); - let reach = if reach.is_finite() { reach * 1.01 } else { 1.0 }; - // Arc points at most ARC of a turn apart, at a radius where their chords stay outside - // `reach`. - const ARC: f64 = 1.0 / 32.0; - let r = reach / (std::f64::consts::PI * ARC).cos(); - let at = |turn: f64| { - let a = turn * std::f64::consts::TAU; - frame * Point::new(r * a.cos(), r * a.sin()) - }; - let center = frame * Point::ORIGIN; + let r = fan_radius(frame, b); (0..n) .map(|k| { let from = k as f64 / n as f64; let to = if overlap { 1.0 } else { (k + 1) as f64 / n as f64 }; - let steps = ((to - from) / ARC).ceil().max(1.0) as usize; - let mut pts = Vec::with_capacity(steps + 2); - pts.push(center); - pts.extend((0..=steps).map(|i| at(from + (to - from) * i as f64 / steps as f64))); - (pts, (k as f64 + 0.5) / n as f64) + (fan(frame, r, from, to), (k as f64 + 0.5) / n as f64) }) .collect() } + /// The frame a diamond gradient is drawn in: its [`Self::unit_frame`], which maps the square + /// with corners (±1, 0) and (0, ±1) onto the diamond (its first corner at the end of the + /// vector). `None` when the square is flat or not finite (a vector without length, an aspect + /// of 0, or a file's out-of-range numbers): such a diamond has no inside, and every point + /// takes its last stop. + pub fn diamond_frame(&self) -> Option { + self.unit_frame().filter(|m| m.is_finite() && m.determinant().abs() > 1e-18) + } + + /// A diamond gradient as four linear gradients, one for each quadrant of its + /// [`Self::diamond_frame`], for formats without diamond gradients: each quadrant's outline (the + /// center, then an arc that reaches past every corner of `b`) and the linear gradient that + /// equals the diamond there (its stops run across the square's edge in that quadrant), in the + /// order they are drawn. Each of the first three runs on past its last edge under the next, + /// and the first also back past its first edge under the last, so every edge drawn lies on + /// the quadrant before it and no hairline of what lies below shows between them; where they + /// overlap, translucent stops would add up, so the stops must be opaque. `None` for a diamond + /// without a frame. + pub fn diamond_quadrants(&self, b: Rect) -> Option<[(Vec, GradientGeom); 4]> { + let frame = self.diamond_frame()?; + let r = fan_radius(frame, b); + Some(std::array::from_fn(|k| { + let first = k as f64 / 4.0; + let (from, to) = match k { + 0 => (-0.125, 0.375), + 3 => (0.75, 1.0), + _ => (first, first + 0.375), + }; + // The quadrant's corner of the square, halfway along whose edge t is 1/2. + let (sx, sy) = [(1.0, 1.0), (-1.0, 1.0), (-1.0, -1.0), (1.0, -1.0)][k]; + let mut geom = GradientGeom { start: Point::ORIGIN, end: Point::new(sx / 2.0, sy / 2.0), aspect: 1.0, focal: None }; + geom.transform(frame, GradientKind::Linear); + (fan(frame, r, from, to), geom) + })) + } + /// The map that squashes a radial gradient's circle (centre `start`, radius the vector's /// length) into its extent ellipse: by `aspect` across the vector, about the centre. pub fn radial_squash(&self) -> Affine { @@ -907,6 +963,34 @@ impl GradientGeom { } } +/// Arc points of [`fan`] are at most this much of a turn apart. +const FAN_ARC: f64 = 1.0 / 32.0; + +/// The radius of the unit circle of `frame` at which the chords of [`fan`]'s arcs stay outside +/// every corner of `b`. +fn fan_radius(frame: Affine, b: Rect) -> f64 { + let to_unit = frame.inverse(); + let corners = [b.origin(), Point::new(b.x1, b.y0), Point::new(b.x1, b.y1), Point::new(b.x0, b.y1)]; + let reach = corners.iter().map(|&c| (to_unit * c).to_vec2().hypot()).fold(0.0f64, f64::max).max(1e-6); + let reach = if reach.is_finite() { reach * 1.01 } else { 1.0 }; + reach / (std::f64::consts::PI * FAN_ARC).cos() +} + +/// The sector of `frame`'s unit circle, scaled to radius `r`, from turn `from` to turn `to` +/// (counted from the frame's x axis toward its y axis) as an outline: the center, then points on +/// the arc at most [`FAN_ARC`] of a turn apart. +fn fan(frame: Affine, r: f64, from: f64, to: f64) -> Vec { + let at = |turn: f64| { + let a = turn * std::f64::consts::TAU; + frame * Point::new(r * a.cos(), r * a.sin()) + }; + let steps = ((to - from) / FAN_ARC).ceil().max(1.0) as usize; + let mut pts = Vec::with_capacity(steps + 2); + pts.push(frame * Point::ORIGIN); + pts.extend((0..=steps).map(|i| at(from + (to - from) * i as f64 / steps as f64))); + pts +} + /// `p` moved from box `from` to box `to`, keeping its position relative to the box (along a side /// `from` has no extent on, its offset from the centre). fn rebase_point(p: Point, from: Rect, to: Rect) -> Point { @@ -1009,7 +1093,7 @@ impl GradientPaint { pub fn draws_as_pixels(&self, fill: bool) -> bool { self.gradient.kind == GradientKind::Freeform || (fill && self.gradient.dither) || !self.gradient.method.is_classic() } - /// A linear, radial or conic gradient placed on box `bounds` as a `cols` × `rows` grid of + /// A linear, radial, conic or diamond gradient placed on box `bounds` as a `cols` × `rows` grid of /// unpremultiplied RGBA8 pixels over `area`, row by row, each sampled at its centre (colours /// as `rgb` gives them, mixed as the renderer mixes them: [`Ramp`]). Dithered and painting a /// fill (`fill`; dither applies to fills only), each colour channel of pixel (column, row) gets @@ -1040,7 +1124,7 @@ impl GradientPaint { ) -> impl Iterator + use<> { self.grid(Ramp::new(&self.gradient, rgb), bounds, area, cols, rows, cells, fill, false) } - /// [`Self::pixels`] of a linear, radial or conic gradient whose stops are all CMYK, as C, M, Y, K + /// [`Self::pixels`] of a linear, radial, conic or diamond gradient whose stops are all CMYK, as C, M, Y, K /// and opacity levels mixed from the stops' inks; `None` when a stop is in another model, /// there are no stops, or a Perceptual curve has too many stops for [`Gradient::ink_mixes`]. /// Dither's noise adds no ink where the ramp has none. diff --git a/crates/color/src/gradient/tests.rs b/crates/color/src/gradient/tests.rs index c8d0a4bb64..0709d3068b 100644 --- a/crates/color/src/gradient/tests.rs +++ b/crates/color/src/gradient/tests.rs @@ -880,3 +880,177 @@ fn conic_pixels_follow_the_sweep() { let many = GradientPaint { gradient: Gradient::new(GradientKind::Conic, many), ..g }; assert_eq!(many.pixels(b, b, 4, 4, true, &Color::to_rgb).count(), 16); } + +/// A diamond placed at `center` with its vector `dx` long to the right and aspect `aspect`. +fn diamond_at(center: Point, dx: f64, aspect: f64) -> GradientGeom { + GradientGeom { start: center, end: center + Vec2::new(dx, 0.0), aspect, focal: None } +} + +#[test] +fn a_diamond_runs_from_its_center_out_to_a_square_with_a_corner_at_the_end_of_its_vector() { + let g = diamond_at(Point::new(100.0, 100.0), 50.0, 1.0); + let t = |x: f64, y: f64| g.param_at(GradientKind::Diamond, Point::new(x, y)); + assert!(t(100.0, 100.0).abs() < 1e-12); + // The square's corners: at the end of the vector and a quarter turn on from it each way. + for (x, y) in [(150.0, 100.0), (100.0, 150.0), (50.0, 100.0), (100.0, 50.0)] { + assert!((t(x, y) - 1.0).abs() < 1e-12, "({x}, {y})"); + } + // Halfway along an edge, and halfway out along a diagonal (the middle of the edge's half size). + assert!((t(125.0, 125.0) - 1.0).abs() < 1e-12); + assert!((t(112.5, 87.5) - 0.5).abs() < 1e-12); + // Past the square: more than 1 (the last stop pads on). + assert!((t(200.0, 200.0) - 4.0).abs() < 1e-12); + // The aspect stretches the square across the vector into a rhombus. + let tall = diamond_at(Point::new(100.0, 100.0), 50.0, 2.0); + assert!((tall.param_at(GradientKind::Diamond, Point::new(100.0, 200.0)) - 1.0).abs() < 1e-12); + assert!((tall.param_at(GradientKind::Diamond, Point::new(150.0, 100.0)) - 1.0).abs() < 1e-12); + // Turned: the corner follows the vector. + let turned = GradientGeom { end: Point::new(130.0, 140.0), ..g }; + assert!((turned.param_at(GradientKind::Diamond, Point::new(130.0, 140.0)) - 1.0).abs() < 1e-12); + assert!((turned.param_at(GradientKind::Diamond, Point::new(115.0, 120.0)) - 0.5).abs() < 1e-12); +} + +#[test] +fn diamond_kind_names_and_fit() { + for s in ["diamond", "Diamond", "DIAMOND"] { + assert_eq!(GradientKind::parse(s), Some(GradientKind::Diamond), "{s}"); + } + assert_eq!(GradientKind::Diamond.label(), "Diamond"); + assert!(GradientKind::Diamond.is_centered()); + assert!(GradientKind::ALL.contains(&GradientKind::Diamond)); + // Level: the square's edges pass through the box's corners, so it covers the box exactly. + let b = Rect::new(0.0, 0.0, 200.0, 100.0); + let fit = GradientGeom::fit(GradientKind::Diamond, b, 0.0); + assert_eq!(fit.start, Point::new(100.0, 50.0)); + assert!(fit.end.distance(Point::new(250.0, 50.0)) < 1e-9, "{fit:?}"); + for c in [Point::new(0.0, 0.0), Point::new(200.0, 0.0), Point::new(200.0, 100.0), Point::new(0.0, 100.0)] { + assert!((fit.param_at(GradientKind::Diamond, c) - 1.0).abs() < 1e-9, "{c:?}"); + } + // At any angle the square covers the box and runs through its farthest corner, on a square + // box too. + for b in [b, Rect::new(0.0, 0.0, 100.0, 100.0)] { + for angle in [30.0, 45.0, 90.0, -120.0] { + let fit = GradientGeom::fit(GradientKind::Diamond, b, angle); + let levels = [Point::new(b.x0, b.y0), Point::new(b.x1, b.y0), Point::new(b.x1, b.y1), Point::new(b.x0, b.y1)] + .map(|c| fit.param_at(GradientKind::Diamond, c)); + assert!(levels.iter().all(|&t| t <= 1.0 + 1e-9), "{angle}: {levels:?}"); + assert!(levels.iter().any(|&t| (t - 1.0).abs() < 1e-9), "{angle}: {levels:?}"); + } + } +} + +#[test] +fn a_diamond_follows_moves_turns_scales_and_mirrors() { + let c = Point::new(10.0, 20.0); + let g0 = diamond_at(c, 30.0, 0.5); + let probes = [c + Vec2::new(5.0, 7.0), c + Vec2::new(-9.0, 2.0), c + Vec2::new(3.0, -11.0), c + Vec2::new(-20.0, -4.0)]; + for a in [ + Affine::rotate(0.7), + Affine::scale(2.5), + Affine::translate((4.0, -9.0)) * Affine::rotate(-2.0), + Affine::FLIP_X, + Affine::FLIP_Y * Affine::rotate(1.1), + // Along and across the vector. + Affine::scale_non_uniform(2.0, 0.5), + Affine::scale_non_uniform(-3.0, 0.25), + ] { + let mut g = g0; + g.transform(a, GradientKind::Diamond); + assert!(g.start.distance(a * c) < 1e-9, "{a:?}"); + // A mirror changes nothing a diamond shows: its aspect stays a size. + assert!(g.aspect > 0.0, "{a:?}: {g:?}"); + for p in probes { + let (want, got) = (g0.param_at(GradientKind::Diamond, p), g.param_at(GradientKind::Diamond, a * p)); + assert!((want - got).abs() < 1e-9, "{a:?} at {p:?}: {want} vs {got}"); + } + } + // Under a shear the corner stays where the vector maps. + let shear = Affine::new([1.0, 0.0, 0.6, 1.0, 0.0, 0.0]); + let mut g = g0; + g.transform(shear, GradientKind::Diamond); + assert!((g.param_at(GradientKind::Diamond, shear * (c + Vec2::new(30.0, 0.0))) - 1.0).abs() < 1e-9, "{g:?}"); + // Out-of-range numbers: a finite aspect after any transform. + for bad in [diamond_at(c, 30.0, f64::NAN), diamond_at(c, f64::INFINITY, 1.0), diamond_at(c, 0.0, 1.0)] { + let mut g = bad; + g.transform(Affine::rotate(0.4), GradientKind::Diamond); + assert!(g.aspect.is_finite(), "{g:?}"); + } +} + +/// A diamond without a frame (no vector, a flat or a not-finite extent) has no inside: every +/// point is past its edge and takes the last stop. Points that aren't numbers do too. +#[test] +fn a_diamond_with_no_extent_or_bad_numbers_is_its_last_stop() { + let c = Point::new(50.0, 50.0); + for g in [ + diamond_at(c, 0.0, 1.0), + diamond_at(c, 30.0, 0.0), + diamond_at(c, 30.0, f64::NAN), + diamond_at(c, f64::INFINITY, 1.0), + GradientGeom { start: Point::new(f64::NAN, 0.0), ..diamond_at(c, 30.0, 1.0) }, + ] { + assert!(g.diamond_frame().is_none(), "{g:?}"); + assert!(g.diamond_quadrants(Rect::new(0.0, 0.0, 100.0, 100.0)).is_none(), "{g:?}"); + assert_eq!(g.param_at(GradientKind::Diamond, Point::new(60.0, 40.0)), 1.0, "{g:?}"); + } + let g = diamond_at(c, 30.0, 1.0); + assert_eq!(g.param_at(GradientKind::Diamond, Point::new(f64::NAN, 1.0)), 1.0); + assert!(g.param_at(GradientKind::Diamond, Point::new(f64::INFINITY, 1.0)) >= 1.0); +} + +/// The four linear gradients a format without diamond gradients gets, drawn in order, equal the +/// diamond everywhere, and each edge drawn lies on the quadrant drawn before it. +#[test] +fn diamond_quadrants_are_linear_gradients_that_equal_the_diamond() { + let b = Rect::new(0.0, 0.0, 200.0, 100.0); + let probes: Vec = (0..=20).flat_map(|i| (0..=10).map(move |j| Point::new(i as f64 * 10.0 + 0.3, j as f64 * 10.0 + 0.7))).collect(); + // Squashed and turned, stretched twice as tall as wide, and stretched by a shear. + let mut turned = diamond_at(Point::new(60.0, 40.0), 30.0, 0.5); + turned.transform(Affine::rotate_about(0.4, Point::new(60.0, 40.0)), GradientKind::Diamond); + let mut sheared = diamond_at(Point::new(90.0, 50.0), 25.0, 1.0); + sheared.transform(Affine::new([2.0, 0.0, 0.7, 1.0, -60.0, 0.0]), GradientKind::Diamond); + for g in [turned, diamond_at(Point::new(100.0, 50.0), 20.0, 2.0), sheared] { + let quadrants = g.diamond_quadrants(b).expect("a frame"); + for p in &probes { + // The last quadrant drawn that holds the point paints it. + let Some((_, linear)) = quadrants.iter().rev().find(|(poly, _)| inside(poly, *p)) else { panic!("{p:?} is in no quadrant") }; + let (want, got) = (g.param_at(GradientKind::Diamond, *p), linear.param_at(GradientKind::Linear, *p)); + assert!((want - got).abs() < 1e-9, "{g:?} at {p:?}: {want} vs {got}"); + } + } + let g = turned; + let quadrants = g.diamond_quadrants(b).expect("a frame"); + // Next to each edge of a quadrant drawn later, on either side, the quadrant drawn before it + // lies underneath (the first quadrant's edges are covered by the second and the last). + let frame = g.diamond_frame().expect("a frame"); + for (unit, under) in [ + // The second quadrant's first edge, on the first. + (Point::new(0.01, 0.5), 0), + (Point::new(-0.01, 0.5), 0), + // The third's, on the second. + (Point::new(-0.5, 0.01), 1), + (Point::new(-0.5, -0.01), 1), + // The last's two, on the third and on the first. + (Point::new(-0.01, -0.5), 2), + (Point::new(0.01, -0.5), 2), + (Point::new(0.5, -0.01), 0), + (Point::new(0.5, 0.01), 0), + ] { + assert!(inside(&quadrants[under].0, frame * unit), "{unit:?} lies on quadrant {under}"); + } +} + +/// A diamond's pixels (what SVG and PDF draw a dithered or Perceptual one as, and what the canvas +/// draws) follow the squares. +#[test] +fn diamond_pixels_follow_the_squares() { + let b = Rect::new(0.0, 0.0, 5.0, 5.0); + let mut g = GradientPaint::new(Gradient::new(GradientKind::Diamond, Gradient::default().stops)); + g.geom = Some(diamond_at(Point::new(2.5, 2.5), 2.5, 1.0)); + let px: Vec<[u8; 4]> = g.pixels(b, b, 5, 5, false, &Color::to_rgb).collect(); + // White at the center, black in the corners, and the same on each side of it. + assert!(px[2 * 5 + 2][0] > 200 && px[0][0] < 10 && px[24][0] < 10, "{px:?}"); + assert_eq!(px[2 * 5 + 1], px[2 * 5 + 3]); + assert_eq!(px[5 + 2], px[3 * 5 + 2]); + assert_eq!(px[2 * 5 + 1], px[5 + 2]); +} diff --git a/crates/color/src/palette_io.rs b/crates/color/src/palette_io.rs index 6be0f2abf5..800785f6f7 100644 --- a/crates/color/src/palette_io.rs +++ b/crates/color/src/palette_io.rs @@ -205,6 +205,23 @@ fn write_css(lib: &SwatchLibrary, keep: impl Fn(&&Swatch) -> bool) -> String { GradientKind::Conic => { format!("conic-gradient(from {}deg, {})", (90.0 - g.angle).rem_euclid(360.0).round(), stops.join(", ")) } + // CSS has no diamond gradient: four layers, each a quarter of the box with + // the stops running from the center out to its corner at a diagonal angle, + // make the diamond its fit gives a box of any shape: the level at a point + // is its distance from the center along both axes over the box's + // half-width plus half-height + // (https://www.w3.org/TR/css-images-3/#linear-gradients). Turned by an angle + // that is not a multiple of 90 degrees, its corners leave the box's axes, + // and a circle stands in for it, as for a radial swatch. + GradientKind::Diamond if (1e-9..90.0 - 1e-9).contains(&g.angle.rem_euclid(90.0)) => { + format!("radial-gradient(circle, {})", stops.join(", ")) + } + GradientKind::Diamond => { + let stops = stops.join(", "); + [("top left", 315), ("top right", 45), ("bottom right", 135), ("bottom left", 225)] + .map(|(corner, deg)| format!("linear-gradient({deg}deg, {stops}) {corner} / 50% 50% no-repeat")) + .join(", ") + } _ => format!("linear-gradient({}deg, {})", (90.0 - g.angle).rem_euclid(360.0).round(), stops.join(", ")), } } diff --git a/crates/color/src/palette_io/tests.rs b/crates/color/src/palette_io/tests.rs index 9ee07cb623..eaee50dd59 100644 --- a/crates/color/src/palette_io/tests.rs +++ b/crates/color/src/palette_io/tests.rs @@ -186,6 +186,55 @@ fn css_writes_conic_gradient_swatches() { assert!(text.contains("--back: conic-gradient(from 0deg, #000000 0%, #ffffff 80%);"), "{text}"); } +/// A diamond gradient swatch (CSS has none) is four layered linear gradients, one a quarter of the +/// box running from the center out to its corner, for the `background` shorthand. +#[test] +fn css_writes_diamond_gradient_swatches_as_four_corner_layers() { + let lib = SwatchLibrary { + name: "x".into(), + swatches: vec![Swatch { + name: "Gem".into(), + paint: Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind: GradientKind::Diamond, ..Gradient::default() }))), + global: false, + spot: false, + }], + groups: vec![], + }; + let text = written(&lib, PaletteFormat::Css); + // Diagonal angles, not `to `: on a box that isn't square, `to ` would turn + // each layer's lines to run through the quarter's other two corners. + let layer = |corner: &str, deg: u32| format!("linear-gradient({deg}deg, #ffffff 0%, #000000 100%) {corner} / 50% 50% no-repeat"); + let want = + format!("--gem: {}, {}, {}, {};", layer("top left", 315), layer("top right", 45), layer("bottom right", 135), layer("bottom left", 225)); + assert!(text.contains(&want), "{text}"); + // CSS's gradient line for a diagonal angle on a quarter box w × h is (w + h) / √2 long and + // starts at the quarter's corner at the box's center: the level at a point x, y from the + // center is (|x| + |y|) / (w + h), the level the diamond fitted to the whole box has there. + let (w, h) = (100.0f64, 50.0f64); + let length = (w + h) / std::f64::consts::SQRT_2; + let fit = crate::GradientGeom::fit(GradientKind::Diamond, kurbo::Rect::new(-w, -h, w, h), 0.0); + for (x, y) in [(0.0, -50.0), (-100.0, 0.0), (-30.0, -20.0), (-100.0, -50.0)] { + // The top-left quarter's layer runs toward (-1, -1). + let css = (-x - y) / std::f64::consts::SQRT_2 / length; + let model = fit.param_at(GradientKind::Diamond, kurbo::Point::new(x, y)); + assert!((css - model).abs() < 1e-9, "({x}, {y}): {css} vs {model}"); + } + // Turned a quarter or a half turn, it is the same diamond; turned 30 degrees, a circle stands + // in for it. + let turned = |angle: f64| { + let mut gp = GradientPaint::new(Gradient { kind: GradientKind::Diamond, ..Gradient::default() }); + gp.angle = angle; + let lib = SwatchLibrary { + name: "x".into(), + swatches: vec![Swatch { name: "Gem".into(), paint: Paint::Gradient(Box::new(gp)), global: false, spot: false }], + groups: vec![], + }; + written(&lib, PaletteFormat::Css) + }; + assert!(turned(90.0).contains(&want) && turned(-180.0).contains(&want), "{}", turned(90.0)); + assert!(turned(30.0).contains("--gem: radial-gradient(circle, #ffffff 0%, #000000 100%);"), "{}", turned(30.0)); +} + #[test] fn css_preserves_fractional_gradient_stop_positions() { for kind in [GradientKind::Linear, GradientKind::Radial] { diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index c057aeb231..b1aa9727ba 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -444,7 +444,8 @@ impl Appearance { } } /// Map placed gradients through a warp, given its local affine approximation at a point (taken - /// at each gradient's center: the start of a radial or conic, the middle of a linear vector). + /// at each gradient's center: the start of a radial, conic or diamond, the middle of a linear + /// vector). /// Freeform points each follow the warp at their own position. pub fn warp_gradients(&mut self, near: &dyn Fn(vectorcraft_geom::Point) -> vectorcraft_geom::Affine) { for g in self.gradients_mut() { diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index 94fbb5ff52..6eae9c1488 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -795,6 +795,10 @@ thread_local! { static LEAVES_TESTED: std::cell::Cell = const { std::cell::Cell::new(0) }; } +/// What a click at `p` hits on leaf object `n`: its outline, strokes and fills, its frame or its +/// bounds, by kind. Raster effects (drop shadows, glows, blurs) do not take clicks: the area they +/// paint beyond the object is left out here and in [`cull_reach`], so a click there reaches the +/// object under it. fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option { #[cfg(test)] LEAVES_TESTED.with(|c| c.set(c.get() + 1)); diff --git a/crates/doc/src/setup.rs b/crates/doc/src/setup.rs index 781bec84ea..bc558d2702 100644 --- a/crates/doc/src/setup.rs +++ b/crates/doc/src/setup.rs @@ -314,6 +314,13 @@ pub struct DocSetup { /// #[serde(default, rename = "systemLayout", skip_serializing_if = "crate::skip::is_default")] pub system_layout: bool, + /// Align New Objects to Pixel Grid (New Document › Advanced Options, Transform panel menu): + /// paths and compound paths drawn from then on are pixel-aligned. Objects already in the + /// document, and objects pasted, duplicated or placed, keep their own setting. Off by default + /// and for documents from before it, and saved only when on. + /// + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub align_new_objects_to_pixel_grid: bool, } impl Default for DocSetup { @@ -337,6 +344,7 @@ impl Default for DocSetup { export_text: ExportText::Editable, background: Background::Transparent, system_layout: false, + align_new_objects_to_pixel_grid: false, } } } diff --git a/crates/doc/src/style_libs.rs b/crates/doc/src/style_libs.rs index f64bdb4300..97d46b7cda 100644 --- a/crates/doc/src/style_libs.rs +++ b/crates/doc/src/style_libs.rs @@ -155,6 +155,7 @@ pub const STYLE_LIBRARIES: &[BuiltinStyleLibrary] = &[ BuiltinStyleLibrary { id: "shadows-glows", name: "Shadows and Glows", make: shadows_glows }, BuiltinStyleLibrary { id: "outlines-rules", name: "Outlines and Rules", make: outlines_rules }, BuiltinStyleLibrary { id: "hand-drawn", name: "Hand-Drawn", make: hand_drawn }, + BuiltinStyleLibrary { id: "textures", name: "Textures", make: textures }, BuiltinStyleLibrary { id: "gradient-finishes", name: "Gradient Finishes", make: gradient_finishes }, BuiltinStyleLibrary { id: "shape-effects", name: "Shape Effects", make: shape_effects }, BuiltinStyleLibrary { id: "blends-transparency", name: "Blends and Transparency", make: blends_transparency }, @@ -322,6 +323,44 @@ fn hand_drawn() -> Vec { ] } +/// Textures: fills and strokes painted with the catalog's texture effects (grain, canvas and +/// burlap surfaces, craquelure, tiles, patchwork, stained glass and water paper). +fn textures() -> Vec { + let surface = |texture: &str, scaling: f64, relief: f64, light: &str| { + fx("texture.texturizer", json!({"texture": texture, "scaling": scaling, "relief": relief, "lightDirection": light, "invert": false})) + }; + vec![ + style( + "Fine Grain", + vec![F(fill("#c9ada7")), S(stroke("#4a4e69", 1.0))], + vec![fx("texture.grain", json!({"intensity": 35.0, "contrast": 55.0, "grainType": "regular"}))], + ), + style("Canvas Weave", vec![F(fill("#e9d8a6"))], vec![surface("canvas", 100.0, 6.0, "topLeft")]), + style("Burlap Sack", vec![F(fill("#bc8a5f")), S(stroke("#6f4518", 1.5))], vec![surface("burlap", 120.0, 8.0, "top")]), + style( + "Cracked Glaze", + vec![F(fill("#94d2bd")), S(stroke("#005f73", 1.0))], + vec![fx("texture.craquelure", json!({"crackSpacing": 12.0, "crackDepth": 5.0, "crackBrightness": 8.0}))], + ), + style( + "Mosaic Floor", + vec![F(fill("#0a9396"))], + vec![fx("texture.mosaicTiles", json!({"tileSize": 10.0, "groutWidth": 3.0, "lightenGrout": 8.0}))], + ), + style("Quilted Squares", vec![F(fill("#ee9b00"))], vec![fx("texture.patchwork", json!({"squareSize": 5.0, "relief": 10.0}))]), + style( + "Leaded Glass", + vec![F(FillLayer::new(linear(-45.0, &[(0.0, "#ae2012", 1.0), (0.5, "#ee9b00", 1.0), (1.0, "#005f73", 1.0)])))], + vec![fx("texture.stainedGlass", json!({"cellSize": 8.0, "borderThickness": 4.0, "lightIntensity": 3.0}))], + ), + style( + "Damp Paper", + vec![F(fill("#4895ef")), S(stroke("#3f37c9", 1.0))], + vec![fx("sketch.waterPaper", json!({"fiberLength": 12.0, "brightness": 55.0, "contrast": 60.0}))], + ), + ] +} + fn gradient_finishes() -> Vec { let rim = |h: &str| S(stroke(h, 1.0)); vec![ @@ -550,6 +589,18 @@ mod tests { assert_eq!(status.name, "Status Effects"); let status_names: Vec<&str> = status.styles.iter().map(|g| g.name.as_str()).collect(); assert_eq!(status_names, ["Poison Tint", "Freeze Edge", "Burn Aura", "Stun Pulse", "Stealth Fade", "Shield Buff"]); + let textures = builtin_style_library("textures").unwrap(); + assert_eq!(textures.name, "Textures"); + let texture_names: Vec<&str> = textures.styles.iter().map(|g| g.name.as_str()).collect(); + let expected = + ["Fine Grain", "Canvas Weave", "Burlap Sack", "Cracked Glaze", "Mosaic Floor", "Quilted Squares", "Leaded Glass", "Damp Paper"]; + assert_eq!(texture_names, expected); + for b in STYLE_LIBRARIES.iter().filter(|b| b.id != "textures") { + let lib = builtin_style_library(b.id).unwrap(); + assert!(lib.styles.iter().all(|g| !expected.contains(&g.name.as_str())), "{} shares a name with Textures", b.id); + } + let ids: Vec<&str> = STYLE_LIBRARIES.iter().map(|b| b.id).collect(); + assert_eq!(ids.iter().position(|&i| i == "textures"), ids.iter().position(|&i| i == "hand-drawn").map(|i| i + 1), "after Hand-Drawn"); } #[test] diff --git a/crates/engine/src/cmd/addons/shapes.rs b/crates/engine/src/cmd/addons/shapes.rs index 24d0e4c3ac..87549e3694 100644 --- a/crates/engine/src/cmd/addons/shapes.rs +++ b/crates/engine/src/cmd/addons/shapes.rs @@ -131,6 +131,8 @@ fn insert_custom(s: &mut Session, label: &str, live: Option, cs: Cust && let Some(n) = std::sync::Arc::make_mut(&mut st.doc).node_mut(NodeId(raw_id)) { cs.write_to_node(n); + // A shape drawn pixel-aligned (Align New Objects to Pixel Grid) snaps its stored outline. + super::super::object::snap_if_aligned(n); } Ok(val) } @@ -270,6 +272,7 @@ fn box3d(s: &mut Session, p: &Value) -> Result { && let Some(n) = std::sync::Arc::make_mut(&mut st.doc).node_mut(NodeId(raw_id)) { cs.write_to_node(n); + super::super::object::snap_if_aligned(n); } Ok(val) } diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index f265c3ff3d..3c829e8a3e 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -162,7 +162,8 @@ fn constrained_turn(s: &Session, rotation: f64) -> f64 { } } -/// [`insert_look`] with the new object's bounding box turned to `bbox_angle`. +/// [`insert_look`] with the new object's bounding box turned to `bbox_angle`. A new path or +/// compound path is pixel-aligned while the document's Align New Objects to Pixel Grid is on. fn insert_look_turned( s: &mut Session, label: &str, @@ -180,6 +181,7 @@ fn insert_look_turned( let id = d.alloc_id(); let mut n = Node::new(id, kind); look.apply(d, &mut n); + super::object::align_new_object(d, &mut n); n.name = name; n.bbox_angle = bbox_angle; let mut into = None; @@ -512,6 +514,7 @@ fn flare(s: &mut Session, p: &Value) -> Result { n.name = Some(name.into()); n.blend = BlendMode::Screen; n.opacity = op; + super::object::align_new_object(d, &mut n); std::sync::Arc::new(n) }) .collect(); @@ -603,6 +606,7 @@ fn grid_group(s: &mut Session, label: &str, (paths, bbox_angle): (Vec, Node::path(d.alloc_id(), pd, Appearance::default()) }; look.apply(d, &mut n); + super::object::align_new_object(d, &mut n); n.bbox_angle = bbox_angle; std::sync::Arc::new(n) }) diff --git a/crates/engine/src/cmd/docsetup.rs b/crates/engine/src/cmd/docsetup.rs index 894d9b90ae..dadafaa1b8 100644 --- a/crates/engine/src/cmd/docsetup.rs +++ b/crates/engine/src/cmd/docsetup.rs @@ -17,7 +17,7 @@ pub fn specs() -> Vec { "Document Setup", [], None, - "{units?: \"Points\"|\"Inches\"|\"Millimeters\"|…, bleed?: pt (all sides)|[top, bottom, left, right]|{top?, bottom?, left?, right?} (0–72 pt), outlineImages?: bool (show images in Outline mode), highlightSubstitutedGlyphs?: bool (Highlight Substituted Fonts is a preference: prefs.set highlightSubstitutedFonts; a highlightSubstitutedFonts key is ignored and the reply carries a warning),gridSize?: \"small\"|\"medium\"|\"large\", gridColors?: \"Light\"|\"Medium\"|\"Dark\"|\"Red\"|\"Orange\"|\"Green\"|\"Blue\"|\"Purple\"|[colour, colour] (transparency grid; the first is also the paper colour; #rrggbbaa and #rgba are refused), simulatePaper?: bool, flattenerPreset?: \"High Resolution\"|\"Medium Resolution\"|\"Low Resolution\"|a saved preset (flattener.presets.list), discardWhiteOverprint?: bool, language?: \"English: USA\"|\"German\"|\"French\"|… (also picks its quotes), quotes?: {double?: \"“”\", single?: \"‘’\"} (beside a language: only if they differ from the current ones), typographersQuotes?: bool (typed straight quotes become the document's quotes), superscript?: {size?: %, position?: %}, subscript?: {size?: %, position?: %}, smallCapsSize?: %, exportText?: \"editable\"|\"appearance\" (SVG text as text or as outlines), backgroundContents?: \"transparent\"|\"white\" (white: raster exports are white behind the art), …the keys of its own reply (it can be passed back; the values it only reports are ignored)} change the document setup in one undo step; no params → the current setup", + "{units?: \"Points\"|\"Inches\"|\"Millimeters\"|…, bleed?: pt (all sides)|[top, bottom, left, right]|{top?, bottom?, left?, right?} (0–72 pt), outlineImages?: bool (show images in Outline mode), highlightSubstitutedGlyphs?: bool (Highlight Substituted Fonts is a preference: prefs.set highlightSubstitutedFonts; a highlightSubstitutedFonts key is ignored and the reply carries a warning),gridSize?: \"small\"|\"medium\"|\"large\", gridColors?: \"Light\"|\"Medium\"|\"Dark\"|\"Red\"|\"Orange\"|\"Green\"|\"Blue\"|\"Purple\"|[colour, colour] (transparency grid; the first is also the paper colour; #rrggbbaa and #rgba are refused), simulatePaper?: bool, flattenerPreset?: \"High Resolution\"|\"Medium Resolution\"|\"Low Resolution\"|a saved preset (flattener.presets.list), discardWhiteOverprint?: bool, language?: \"English: USA\"|\"German\"|\"French\"|… (also picks its quotes), quotes?: {double?: \"“”\", single?: \"‘’\"} (beside a language: only if they differ from the current ones), typographersQuotes?: bool (typed straight quotes become the document's quotes), superscript?: {size?: %, position?: %}, subscript?: {size?: %, position?: %}, smallCapsSize?: %, exportText?: \"editable\"|\"appearance\" (SVG text as text or as outlines), backgroundContents?: \"transparent\"|\"white\" (white: raster exports are white behind the art), alignNewObjectsToPixelGrid?: bool (Align New Objects to Pixel Grid: paths and compound paths drawn from then on are pixel-aligned, as object.setPixelAligned; art already in the document, pasted, duplicated or placed keeps its own setting), …the keys of its own reply (it can be passed back; the values it only reports are ignored)} change the document setup in one undo step; no params → the current setup", has_doc, setup )] @@ -46,6 +46,7 @@ pub(crate) fn setup_json(d: &Document) -> Value { "smallCapsSize": s.small_caps_size, "exportText": s.export_text.id(), "backgroundContents": s.background.id(), + "alignNewObjectsToPixelGrid": s.align_new_objects_to_pixel_grid, }) } @@ -140,6 +141,7 @@ fn apply(s: &mut DocSetup, units: &mut vectorcraft_doc::Unit, k: &str, v: &Value "smallCapsSize" => s.small_caps_size = percent(k, v, 1.0, vectorcraft_doc::setup::MAX_SMALL_CAPS)?, "exportText" => s.export_text = v.as_str().and_then(ExportText::parse).ok_or_else(|| bad(C, "exportText must be editable or appearance"))?, "backgroundContents" => s.background = background_param(v, C)?, + "alignNewObjectsToPixelGrid" => s.align_new_objects_to_pixel_grid = flag(k, v)?, // Read-only values `document.setup` reports. "gridColorsName" | "warnings" => {} _ => return Err(bad(C, format!("unknown setting `{k}`"))), diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 1632787eb6..108b8593c9 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -23,7 +23,7 @@ pub fn specs() -> Vec { "New…", ["File"], None, - "{preset?: a name from file.newPresets (start from it; the other params change it), name?|title?: document name (default Untitled-N), width?: pt=612|\"210 mm\", height?: pt=792, units?: \"Pixels\"|\"Points\"|\"Picas\"|\"Inches\"|\"Millimeters\"|\"Centimeters\"|\"Feet\"|\"Yards\"|\"Meters\"|\"Feet & Inches\" (default: prefs unitsGeneral, as print presets; screen presets: Pixels), orientation?: \"portrait\"|\"landscape\" (swaps width and height to match), artboards?: n (1–1000), artboardLayout?: {layout?: \"gridByRow\"|\"gridByColumn\"|\"row\"|\"column\", columns?: n (default: all in one row), spacing?: pt=20, rightToLeft?: bool}, bleed?: pt|[top, bottom, left, right]|{top?, …} (0–72 pt), backgroundContents?: \"transparent\"|\"white\" (a white artboard background, not an object), colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK), rasterEffectsPpi?: 72|150|300 (1–2400), previewMode?: \"default\"|\"pixel\"|\"overprint\" (overprint turns Overprint Preview on, pixel Pixel Preview in the app; default leaves both), created?: Unix seconds|null (File Info's created date; default now, recorded in the journal so a replay matches), size?: a preset's size label (a preset file.newPresets lists can be passed back; ignored)} → {index, previewMode}; the size is remembered in file.newPresets' Recent", + "{preset?: a name from file.newPresets (start from it; the other params change it), name?|title?: document name (default Untitled-N), width?: pt=612|\"210 mm\", height?: pt=792, units?: \"Pixels\"|\"Points\"|\"Picas\"|\"Inches\"|\"Millimeters\"|\"Centimeters\"|\"Feet\"|\"Yards\"|\"Meters\"|\"Feet & Inches\" (default: prefs unitsGeneral, as print presets; screen presets: Pixels), orientation?: \"portrait\"|\"landscape\" (swaps width and height to match), artboards?: n (1–1000), artboardLayout?: {layout?: \"gridByRow\"|\"gridByColumn\"|\"row\"|\"column\", columns?: n (default: all in one row), spacing?: pt=20, rightToLeft?: bool}, bleed?: pt|[top, bottom, left, right]|{top?, …} (0–72 pt), backgroundContents?: \"transparent\"|\"white\" (a white artboard background, not an object), colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK), rasterEffectsPpi?: 72|150|300 (1–2400), previewMode?: \"default\"|\"pixel\"|\"overprint\" (overprint turns Overprint Preview on, pixel Pixel Preview in the app; default leaves both), alignNewObjectsToPixelGrid?: bool (Advanced Options › Align New Objects to Pixel Grid: paths drawn in the document are pixel-aligned; default on for the Mobile and Web presets, else off; document.setup changes it later), created?: Unix seconds|null (File Info's created date; default now, recorded in the journal so a replay matches), size?: a preset's size label (a preset file.newPresets lists can be passed back; ignored)} → {index, previewMode}; the size is remembered in file.newPresets' Recent", always, super::newdoc::file_new ), diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index 935d5e9dc5..1d39e2b4e4 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -47,7 +47,7 @@ pub fn specs() -> Vec { "Expand…", ["Object"], None, - "{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients, wedges around the center for conic ones) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step; a dimension whose art an option changes becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines and label stay as they are, and if the new art no longer fits its link it stays where it was, no longer linked → {ids}", + "{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients, wedges around the center for conic ones, nested squares for diamond ones) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step; a dimension whose art an option changes becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines and label stay as they are, and if the new art no longer fits its link it stays where it was, no longer linked → {ids}", has_selection, expand ), @@ -1283,9 +1283,12 @@ fn breaks(g: &Gradient, t0: f64, t1: f64, max_gap: f64) -> Vec<(f64, (Color, f32 /// cover the box (linear: along the vector `t0..t1` and across it `s0..s1` in points; radial: /// `0..t1`, each level's ellipse centred on the way from the focal point to the start). /// A conic gradient also has its placement, the box and the radius in its frame that reaches -/// past the box's corners ([`Frame::pieces`] and [`Frame::mesh`] go around its center). +/// past the box's corners ([`Frame::pieces`] and [`Frame::mesh`] go around its center). A +/// diamond gradient has its frame ([`vectorcraft_color::GradientGeom::diamond_frame`]; `None` +/// when it has no extent), the box and the level of the square that reaches its corners. struct Frame { conic: Option<(vectorcraft_color::GradientGeom, Rect, f64)>, + diamond: Option<(Option, Rect, f64)>, radial: bool, start: Point, focal: Point, @@ -1323,7 +1326,11 @@ impl Frame { let reach = if reach.is_finite() { reach * 1.01 / (std::f64::consts::PI * CONIC_MESH_GAP).cos() } else { 1.0 }; (geom, bounds, reach) }); - Self { conic, radial, start: geom.start, focal: geom.focal_point(), along, across, t0, t1, s0, s1 } + let diamond = (g.gradient.kind == GradientKind::Diamond).then(|| { + let reach = corners.iter().map(|c| geom.param_at(GradientKind::Diamond, *c)).fold(0.0f64, f64::max); + (geom.diamond_frame(), bounds, if reach.is_finite() { reach.max(1e-6) * 1.01 } else { 1.0 }) + }); + Self { conic, diamond, radial, start: geom.start, focal: geom.focal_point(), along, across, t0, t1, s0, s1 } } /// Linear: the point at parameter `t` along and `s` across. Radial: the point at parameter `t` @@ -1351,6 +1358,34 @@ impl Frame { }; return GradientMesh::from_grid(&us, &[0.0, 1.0], &at, &|_, c, _| br[c].1); } + if let Some((frame, b, reach)) = &self.diamond { + let Some(frame) = frame else { + // No extent: the whole box is the last stop. + let last = g.sample_keep_model(1.0); + let at = |u: f64, v: f64| Point::new(b.x0 + u * b.width(), b.y0 + v * b.height()); + return GradientMesh::from_grid(&[0.0, 1.0], &[0.0, 1.0], &at, &|_, _, _| last); + }; + // Rings of squares where the color changes, with columns at the corners: each patch + // lies between two rings along one side, where the level runs straight across it. + let br = breaks(g, 0.0, *reach, f64::INFINITY); + let ts: Vec = br.iter().map(|(t, _)| t / reach).collect(); + let at = |u: f64, v: f64| *frame * (diamond_perimeter(u).to_vec2() * (v * reach)).to_point(); + const CORNERS: [f64; 5] = [0.0, 0.25, 0.5, 0.75, 1.0]; + let mut mesh = GradientMesh::from_grid(&CORNERS, &ts, &at, &|r, _, _| br[r].1); + // Every column is at a corner, where `from_grid`'s tangent is the mean of the two + // sides: each handle along a ring points a third of the way along its own side, so + // the sides stay straight. + let side = |v: f64, from: usize, to: Option<&f64>| to.map_or(vectorcraft_geom::Vec2::ZERO, |&u| (at(u, v) - at(CORNERS[from], v)) / 3.0); + for (r, &v) in ts.iter().enumerate() { + for c in 0..CORNERS.len() { + if let Some(point) = mesh.points.get_mut(r * CORNERS.len() + c) { + point.handles[0] = side(v, c, CORNERS.get(c + 1)); + point.handles[1] = side(v, c, c.checked_sub(1).and_then(|i| CORNERS.get(i))); + } + } + } + return mesh; + } // Rings as close as the columns around them, so the mesh draws smoothly. let gap = if self.radial { (self.t1 - self.t0) / RADIAL_MESH_COLS as f64 } else { f64::INFINITY }; let br = breaks(g, self.t0, self.t1, gap); @@ -1367,9 +1402,9 @@ impl Frame { /// `n` solid shapes covering the box in paint order, each coloured as the gradient in the /// middle of its band: for a linear gradient, rectangles from each band's start to the far /// end, for a radial one, nested ellipses (largest first), for a conic one, wedges around the - /// center from each band's start to the seam. Each paints over the ones below except its - /// band, so no seam shows between bands. With translucent stops each shape is just its band, - /// so nothing doubles up. + /// center from each band's start to the seam, for a diamond one, nested squares (largest + /// first). Each paints over the ones below except its band, so no seam shows between bands. + /// With translucent stops each shape is just its band, so nothing doubles up. fn pieces(&self, g: &Gradient, n: usize) -> Vec<(PathData, (Color, f32))> { let opaque = g.stops.iter().all(|s| s.opacity >= 1.0); if let Some((geom, b, _)) = &self.conic { @@ -1379,6 +1414,29 @@ impl Frame { .map(|(pts, t)| (PathData::single(SubPath::polyline(&pts, true)), g.sample_keep_model(t as f32))) .collect(); } + if let Some((frame, b, reach)) = &self.diamond { + let Some(frame) = frame else { + return vec![(shapes::rectangle(*b), g.sample_keep_model(1.0))]; + }; + // Nested squares about the center, largest first, as a radial gradient's ellipses. + let step = reach / n as f64; + let square = |t: f64| -> SubPath { + let pts: Vec = [0.0, 0.25, 0.5, 0.75].iter().map(|&u| *frame * (diamond_perimeter(u).to_vec2() * t).to_point()).collect(); + SubPath::polyline(&pts, true) + }; + return (0..n) + .rev() + .map(|k| { + let mut shape = PathData::single(square((k + 1) as f64 * step)); + if !opaque && k > 0 { + let mut hole = square(k as f64 * step); + hole.reverse(); + shape.subpaths.push(hole); + } + (shape, g.sample_keep_model(((k as f64 + 0.5) * step) as f32)) + }) + .collect(); + } let step = (self.t1 - self.t0) / n as f64; let color = |k: usize| g.sample_keep_model((self.t0 + (k as f64 + 0.5) * step) as f32); if self.radial { @@ -1410,3 +1468,14 @@ impl Frame { .collect() } } + +/// The point a fraction `u` (0..1) of the way around the square with corners (1, 0), (0, 1), +/// (-1, 0) and (0, -1), from the first: a diamond gradient's last stop in its frame. +fn diamond_perimeter(u: f64) -> Point { + const CORNERS: [(f64, f64); 5] = [(1.0, 0.0), (0.0, 1.0), (-1.0, 0.0), (0.0, -1.0), (1.0, 0.0)]; + let s = if u.is_finite() { u.clamp(0.0, 1.0) * 4.0 } else { 0.0 }; + let i = (s.floor() as usize).min(3); + let f = s - i as f64; + let ((x0, y0), (x1, y1)) = (CORNERS.get(i).copied().unwrap_or((1.0, 0.0)), CORNERS.get(i + 1).copied().unwrap_or((1.0, 0.0))); + Point::new(x0 + (x1 - x0) * f, y0 + (y1 - y0) * f) +} diff --git a/crates/engine/src/cmd/fileio/action_batch.rs b/crates/engine/src/cmd/fileio/action_batch.rs index 97b44597b6..5e920e1b3c 100644 --- a/crates/engine/src/cmd/fileio/action_batch.rs +++ b/crates/engine/src/cmd/fileio/action_batch.rs @@ -11,7 +11,7 @@ use serde_json::json; use super::super::{Result, Session, bad}; #[cfg(not(target_arch = "wasm32"))] -use super::super::{bool_or, find_command, str_param}; +use super::super::{check_switches, find_command, switch_or, text_param}; #[cfg(target_arch = "wasm32")] pub(super) fn action_batch(_: &mut Session, _: &Value) -> Result { @@ -68,12 +68,24 @@ pub(super) fn action_batch(s: &mut Session, p: &Value) -> Result { )); } } - let source = str_param(p, "source").unwrap_or("folder"); - let destination = str_param(p, "destination").unwrap_or("none"); + // Every option is checked before the destination folder is made. + let source = text_param(p, "source", C)?.unwrap_or("folder"); + if !matches!(source, "folder" | "datasets") { + return Err(bad(C, "`source` must be `folder` or `datasets`")); + } + let destination = text_param(p, "destination", C)?.unwrap_or("none"); if !matches!(destination, "none" | "saveAndClose" | "folder") { return Err(bad(C, "`destination` must be `none`, `saveAndClose` or `folder`")); } - let dest_folder = str_param(p, "destinationFolder").map(absolute_path); + let dest_folder = text_param(p, "destinationFolder", C)?.map(absolute_path); + text_param(p, "folder", C)?; + let label = text_param(p, "label", C)?.unwrap_or("Batch").to_string(); + let dataset_file_naming = text_param(p, "datasetFileNaming", C)?.unwrap_or("fileDataSetName"); + let override_open = switch_or(p, "overrideOpen", true, C)?; + let stop_on_error = switch_or(p, "stopOnError", true, C)?; + let override_save = switch_or(p, "overrideSave", true, C)?; + let override_export = switch_or(p, "overrideExport", true, C)?; + check_switches(p, &["includeSubdirs"], C)?; if destination == "folder" { let Some(ref folder) = dest_folder else { return Err(bad(C, "`destinationFolder` is required when destination is `folder`")); @@ -81,17 +93,15 @@ pub(super) fn action_batch(s: &mut Session, p: &Value) -> Result { may_write(folder)?; std::fs::create_dir_all(folder).map_err(|e| bad(C, format!("{folder}: {e}")))?; } - let label = str_param(p, "label").unwrap_or("Batch").to_string(); - let override_open = bool_or(p, "overrideOpen", true); let run = Run { label: &label, steps: cmds, - stop_on_error: bool_or(p, "stopOnError", true), - override_save: bool_or(p, "overrideSave", true), - override_export: bool_or(p, "overrideExport", true), + stop_on_error, + override_save, + override_export, destination, dest_folder: dest_folder.as_deref(), - dataset_file_naming: p.get("datasetFileNaming").map_or("fileDataSetName", |v| v.as_str().unwrap_or("")), + dataset_file_naming, }; match source { "folder" => batch_folder(s, C, p, override_open, &run), @@ -186,9 +196,9 @@ fn retargeting_save(c: &Value, redirected: bool) -> bool { fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run: &Run<'_>) -> Result { use super::{absolute_path, may_read}; - let folder = str_param(p, "folder").map(absolute_path).ok_or_else(|| bad(cmd, "missing `folder`"))?; + let folder = text_param(p, "folder", cmd)?.map(absolute_path).ok_or_else(|| bad(cmd, "missing `folder`"))?; may_read(&folder)?; - let include_subdirs = bool_or(p, "includeSubdirs", false); + let include_subdirs = switch_or(p, "includeSubdirs", false, cmd)?; let files = collect_files(&folder, include_subdirs, 0)?; if files.len() > MAX_BATCH_FILES { return Err(bad(cmd, format!("more than {MAX_BATCH_FILES} files"))); @@ -810,6 +820,46 @@ mod tests { assert!(dest.join("b.svg").is_file(), "b.svg written"); } + #[test] + fn batch_refuses_options_of_the_wrong_type_before_it_writes() { + let src = tmp("types-src"); + let dest = tmp("types-dest"); + write_svg(&src, "a.svg", "#f00"); + let mut s = Session::new(); + let cases = [ + ("stopOnError", json!("false"), "`stopOnError` must be true or false"), + ("overrideOpen", json!(0), "`overrideOpen` must be true or false"), + ("overrideSave", json!("true"), "`overrideSave` must be true or false"), + ("overrideExport", json!([]), "`overrideExport` must be true or false"), + ("includeSubdirs", json!(1), "`includeSubdirs` must be true or false"), + ("source", json!(1), "`source` must be a string"), + ("source", json!("files"), "`source` must be `folder` or `datasets`"), + ("destination", json!(true), "`destination` must be a string"), + ("folder", json!(5), "`folder` must be a string"), + ("label", json!(5), "`label` must be a string"), + ("datasetFileNaming", json!(3), "`datasetFileNaming` must be a string"), + ]; + for (key, value, message) in cases { + let out = dest.join(key); + let mut p = json!({ + "folder": src.to_string_lossy(), + "destination": "folder", + "destinationFolder": out.to_string_lossy(), + "commands": [{"command": "object.unlockAll", "params": {}}], + }); + p[key] = value; + let e = s.execute("action.batch", &p).unwrap_err().to_string(); + assert!(e.contains(message), "{key}: {e}"); + assert!(!out.exists(), "{key}: the destination folder was created"); + } + let e = s + .execute("action.batch", &json!({"folder": src.to_string_lossy(), "destination": "folder", "destinationFolder": 7, "commands": [{"command": "object.unlockAll"}]})) + .unwrap_err() + .to_string(); + assert!(e.contains("`destinationFolder` must be a string"), "{e}"); + assert!(s.active().is_none(), "no file was opened"); + } + #[cfg(unix)] #[test] fn batch_folder_keeps_backslash_in_source_file_name() { diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 4fe2d011d9..dd2b257f2a 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -117,7 +117,7 @@ pub fn specs() -> Vec { "Batch…", [], None, - "{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close; for source datasets, save the open document over its own file after each data set, and refuse a document that was never saved, was converted from an older version or was restored by Data Recovery, unless a recorded Save or Save As step writes a file first; a Save or Save As step that overrideSave points into destinationFolder makes each data set's file the document's own) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), datasetFileNaming?: fileNumber | fileDataSetName (default) | dataSetName (used only for source datasets; ignored for folder), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportSelection the same way, and give document.exportForScreens / assets.export the folder destinationFolder/{stem}, one for each source file or data set, in which the export names its files as it does outside Batch), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs; {stem} is the file's name without its extension and without trailing periods and spaces, with `-2`, `-3`… added to a name an earlier file used or whose file or folder has the name of one an earlier file wrote or of another source file or a folder of source files in destinationFolder), or on every data set of the open document. Data set names use the document stem plus a three-digit ordinal, the document stem and an underscore plus the data set name, or the data set name alone. Invalid file name characters become `-` and trailing periods and spaces are dropped; a name already used in the batch or whose output file or folder already exists in a destination folder the batch may read and write gets the next free `-2`, `-3` suffix before the format-derived extension. Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped, warning?} (warning: for source datasets, when Save and Close without such a step, or a Save, Save a Copy, Save as Template or export step (document.export, exportSelection, exportForScreens, exportPdf, exportEps, exportDxf, exportForWeb, exportForOffice) that writes a file and is not pointed into destinationFolder, saves every data set over the same file)", + "{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close; for source datasets, save the open document over its own file after each data set, and refuse a document that was never saved, was converted from an older version or was restored by Data Recovery, unless a recorded Save or Save As step writes a file first; a Save or Save As step that overrideSave points into destinationFolder makes each data set's file the document's own) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), datasetFileNaming?: fileNumber | fileDataSetName (default) | dataSetName (used only for source datasets; ignored for folder), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportSelection the same way, and give document.exportForScreens / assets.export the folder destinationFolder/{stem}, one for each source file or data set, in which the export names its files as it does outside Batch), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs; {stem} is the file's name without its extension and without trailing periods and spaces, with `-2`, `-3`… added to a name an earlier file used or whose file or folder has the name of one an earlier file wrote or of another source file or a folder of source files in destinationFolder), or on every data set of the open document. Data set names use the document stem plus a three-digit ordinal, the document stem and an underscore plus the data set name, or the data set name alone. Invalid file name characters become `-` and trailing periods and spaces are dropped; a name already used in the batch or whose output file or folder already exists in a destination folder the batch may read and write gets the next free `-2`, `-3` suffix before the format-derived extension. Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. A switch (includeSubdirs, overrideOpen, overrideSave, overrideExport, stopOnError) that is not true or false, or a source, folder, destination, destinationFolder, datasetFileNaming or label that is not a string, is an error before any folder is made or file opened; null reads as left out. → {processed: [{path|dataset, result|error}…], count, failed, stopped, warning?} (warning: for source datasets, when Save and Close without such a step, or a Save, Save a Copy, Save as Template or export step (document.export, exportSelection, exportForScreens, exportPdf, exportEps, exportDxf, exportForWeb, exportForOffice) that writes a file and is not pointed into destinationFolder, saves every data set over the same file)", always, action_batch::action_batch ), diff --git a/crates/engine/src/cmd/flatten.rs b/crates/engine/src/cmd/flatten.rs index f950f8c1da..02b4097684 100644 --- a/crates/engine/src/cmd/flatten.rs +++ b/crates/engine/src/cmd/flatten.rs @@ -1,7 +1,10 @@ //! Object → Flatten Transparency: transparent art becomes opaque art that looks the same. //! -//! The targets (`ids` or the selection) split into groups of overlapping objects. Groups without -//! transparency stay as they are (their type and strokes are outlined on request). The others are +//! The targets (`ids` or the selection) split into groups: each object with transparency with the +//! objects it overlaps, and the objects without transparency whose stacking order with a member +//! would change when the group takes its topmost object's place. Objects without transparency +//! that overlap only others without it stay as they are (their type and strokes are outlined on +//! request), as do groups without transparency. The others are //! cut into atomic regions: the faces of the planar arrangement of every fill, outlined stroke and //! clipping path in them ([`po::regions`]). The same paints cover each whole region, so its colour //! is exact: those paints composited as the renderer composites them ([`Composer`], blend modes by diff --git a/crates/engine/src/cmd/flatten/analysis.rs b/crates/engine/src/cmd/flatten/analysis.rs index eacd124052..cf7bdb7baa 100644 --- a/crates/engine/src/cmd/flatten/analysis.rs +++ b/crates/engine/src/cmd/flatten/analysis.rs @@ -99,17 +99,77 @@ pub(super) fn masked(n: &Node) -> bool { n.extras().mask.as_ref().is_some_and(|m| !m.disabled) } -/// Where `n` can paint: its visual bounds grown by its effects (and a margin for antialiasing). -pub(super) fn reach(n: &Node) -> Option { +/// Where `n` paints: its visual bounds grown by its effects. +pub(super) fn painted(n: &Node) -> Option { if !n.visible { return None; } let b = match n.children() { - Some(ch) if !n.clips() && !matches!(n.kind, NodeKind::Compound { .. }) => ch.iter().filter_map(|c| reach(c)).reduce(|a, b| a.union(b))?, + Some(ch) if !n.clips() && !matches!(n.kind, NodeKind::Compound { .. }) => ch.iter().filter_map(|c| painted(c)).reduce(|a, b| a.union(b))?, _ => n.visual_bounds()?, }; let fx = n.appearance.items.iter().map(|i| effects::outset(i.effects(), b)).fold(effects::outset(&n.appearance.effects, b), f64::max); - Some(b.inflate(fx + 1.0, fx + 1.0)) + Some(b.inflate(fx, fx)) +} + +/// Where `n` can paint: where it paints ([`painted`]) and a margin of 1 pt for antialiasing, +/// once, however deep its groups nest. +pub(super) fn reach(n: &Node) -> Option { + painted(n).map(|b| b.inflate(1.0, 1.0)) +} + +type Cancelled = vectorcraft_doc::cancel::Cancelled; + +/// Whether `b` has finite coordinates and isn't inside out. +pub(super) fn finite(b: &Rect) -> bool { + [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()) && b.x0 <= b.x1 && b.y0 <= b.y1 +} + +/// Whether boxes `a` and `b` overlap or touch. +fn touch(a: &Rect, b: &Rect) -> bool { + a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1 +} + +/// Indices joined into sets, each set knowing whether it holds an object with transparency. +struct Sets { + parent: Vec, + seeded: Vec, +} + +impl Sets { + fn find(&mut self, mut i: usize) -> usize { + while self.parent[i] != i { + self.parent[i] = self.parent[self.parent[i]]; + i = self.parent[i]; + } + i + } + + /// Joins the sets of `a` and `b`: → the set they are now, named by its lowest index. + fn join(&mut self, a: usize, b: usize) -> usize { + let (a, b) = (self.find(a), self.find(b)); + let (lo, hi) = (a.min(b), a.max(b)); + self.parent[hi] = lo; + self.seeded[lo] |= self.seeded[hi]; + lo + } + + /// The sets, each in index order, by their first index. + fn groups(&mut self) -> Vec> { + let mut groups: Vec> = vec![]; + let mut slot: Vec> = vec![None; self.parent.len()]; + for i in 0..self.parent.len() { + let r = self.find(i); + match slot[r] { + Some(g) => groups[g].push(i), + None => { + slot[r] = Some(groups.len()); + groups.push(vec![i]); + } + } + } + groups + } } /// Indices of `bounds` grouped by overlap (transitively), each group in index order. Boxes that @@ -121,16 +181,15 @@ pub(super) fn reach(n: &Node) -> Option { /// horizontal extent, would otherwise compare every pair). An open box inside a later box of /// its group, ending no further right, is closed early: whatever overlaps it overlaps that box. /// Fails only when cancelled ([`vectorcraft_doc::cancel`]). -pub(super) fn overlapping(bounds: &[Option]) -> Result>, vectorcraft_doc::cancel::Cancelled> { - fn find(p: &mut [usize], mut i: usize) -> usize { - while p[i] != i { - p[i] = p[p[i]]; - i = p[i]; - } - i - } - let mut parent: Vec = (0..bounds.len()).collect(); - let finite = |b: &Rect| [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()) && b.x0 <= b.x1 && b.y0 <= b.y1; +pub(super) fn overlapping(bounds: &[Option]) -> Result>, Cancelled> { + Ok(sweep(bounds, None)?.groups()) +} + +/// [`overlapping`]'s sweep. With `see_through`, two boxes are joined only when one of them is +/// see-through, and an open box is closed early only inside a later see-through box. +fn sweep(bounds: &[Option], see_through: Option<&[bool]>) -> Result { + let mut sets = Sets { parent: (0..bounds.len()).collect(), seeded: (0..bounds.len()).map(|i| see_through.is_some_and(|s| s[i])).collect() }; + let joins = |a: usize, b: usize| see_through.is_none_or(|s| s[a] || s[b]); let boxes: Vec<(usize, Rect)> = bounds.iter().enumerate().filter_map(|(i, b)| b.filter(finite).map(|b| (i, b))).collect(); let n = boxes.len(); // Each box's place in the tree: by top (then index). @@ -190,29 +249,140 @@ pub(super) fn overlapping(bounds: &[Option]) -> Result>, ve stack.push((2 * node + 1, mid, hi)); stack.push((2 * node, lo, mid)); } + // Whatever overlaps a box inside this one overlaps this one, and joins it when this one is + // see-through. + let shadows = see_through.is_none_or(|s| s[boxes[k].0]); for &o in &met { - let (a, c) = (find(&mut parent, boxes[k].0), find(&mut parent, boxes[o].0)); - parent[a.max(c)] = a.min(c); + if joins(boxes[k].0, boxes[o].0) { + sets.join(boxes[k].0, boxes[o].0); + } let ob = boxes[o].1; - if ob.x1 <= b.x1 && b.y0 <= ob.y0 && ob.y1 <= b.y1 { + if shadows && ob.x1 <= b.x1 && b.y0 <= ob.y0 && ob.y1 <= b.y1 { set(&mut low, leaf[o], f64::NEG_INFINITY); } } set(&mut low, leaf[k], b.y1); } - let mut groups: Vec> = vec![]; - let mut slot: Vec> = vec![None; bounds.len()]; - for i in 0..bounds.len() { - let r = find(&mut parent, i); - match slot[r] { - Some(g) => groups[g].push(i), - None => { - slot[r] = Some(groups.len()); - groups.push(vec![i]); + Ok(sets) +} + +/// Boxes in blocks of nearby ones, each block with the box around it, to find the boxes a box +/// touches. +struct Blocks { + order: Vec, + boxes: Vec>, + around: Vec, +} + +impl Blocks { + const SIZE: usize = 32; + + /// The finite boxes of `boxes`, in vertical strips by their centers and in blocks down each + /// strip. + fn new(boxes: &[Option]) -> Self { + let boxes: Vec> = boxes.iter().map(|b| b.filter(finite)).collect(); + let mut order: Vec = (0..boxes.len()).filter(|&i| boxes[i].is_some()).collect(); + let center = |i: usize| boxes[i].map_or_else(Default::default, |b| b.center()); + order.sort_by(|&a, &b| center(a).x.total_cmp(¢er(b).x)); + let strip = ((order.len() as f64 / Self::SIZE as f64).sqrt().ceil() as usize).max(1) * Self::SIZE; + for s in order.chunks_mut(strip) { + s.sort_by(|&a, &b| center(a).y.total_cmp(¢er(b).y)); + } + let around = order.chunks(Self::SIZE).filter_map(|c| c.iter().filter_map(|&i| boxes[i]).reduce(|a, b| a.union(b))).collect(); + Self { order, boxes, around } + } + + /// Puts the indices of the boxes that touch `q` in `out`. `work` counts the boxes looked at; + /// fails only when cancelled. + fn touching(&self, q: &Rect, out: &mut Vec, work: &mut usize) -> Result<(), Cancelled> { + out.clear(); + for (b, around) in self.around.iter().enumerate() { + *work += 1; + if work.is_multiple_of(4096) { + vectorcraft_doc::cancel::check()?; + } + if !touch(around, q) { + continue; + } + for &i in &self.order[b * Self::SIZE..((b + 1) * Self::SIZE).min(self.order.len())] { + *work += 1; + if self.boxes[i].is_some_and(|r| touch(&r, q)) { + out.push(i); + } } } + Ok(()) } - Ok(groups) +} + +/// Objects (in paint order) grouped for flattening, each group in paint order and the groups by +/// their first object; a group takes the place of its topmost object. `reach` is where each +/// object can paint ([`reach`]), `painted` where it paints ([`painted`]), and `see_through` whether +/// it has transparency. An object whose box is missing or not finite is a group of its own. +/// +/// An object joins every see-through object its reach touches. An object without transparency +/// that touches only others without transparency stays a group of its own, unless a group would +/// change its stacking order with it: it joins (and groups join each other) where its reach +/// touches a member's and moving the members to their group's top would put the two the other way +/// round. With a `fringe` (how far a group's image can reach past +/// its art and cover what is under it), an object or group under a group's top whose painted box +/// that far around a member's touches joins it too. Fails only when cancelled +/// ([`vectorcraft_doc::cancel`]). +pub(super) fn flatten_groups( + reach: &[Option], + painted: &[Option], + see_through: &[bool], + fringe: f64, +) -> Result>, Cancelled> { + let n = reach.len(); + let mut sets = sweep(reach, Some(see_through))?; + let mut members: Vec> = vec![vec![]; n]; + for i in 0..n { + let r = sets.find(i); + members[r].push(i); + } + // The topmost object of each set (by its name). + let mut top: Vec = (0..n).map(|r| members[r].last().copied().unwrap_or(r)).collect(); + let index = Blocks::new(reach); + // Each member of a set with transparency is looked at. + let mut work: Vec = vec![]; + for i in 0..n { + let r = sets.find(i); + if sets.seeded[r] { + work.push(i); + } + } + let (mut near, mut looked) = (vec![], 0usize); + while let Some(i) = work.pop() { + let Some(q) = reach[i].filter(finite) else { continue }; + // Painted boxes within the fringe have reaches within it too. + index.touching(&q.inflate(fringe, fringe), &mut near, &mut looked)?; + for &k in &near { + let (ri, rk) = (sets.find(i), sets.find(k)); + if ri == rk { + continue; + } + let (ti, tk) = (top[ri], top[rk]); + let crossed = reach[k].is_some_and(|b| touch(&q, &b)) && (i < k) != (ti < tk); + let covered = fringe > 0.0 && tk < ti && painted[i].zip(painted[k]).is_some_and(|(a, b)| touch(&a.inflate(fringe, fringe), &b)); + if !crossed && !covered { + continue; + } + // The members of a set whose top moves are looked at again, from there. + let t = ti.max(tk); + if ti != t { + work.extend_from_slice(&members[ri]); + } + if tk != t { + work.extend_from_slice(&members[rk]); + } + let r = sets.join(ri, rk); + let moved = std::mem::take(&mut members[if r == ri { rk } else { ri }]); + members[r].extend(moved); + top[r] = t; + } + } + Ok(sets.groups()) } #[cfg(test)] @@ -292,4 +462,153 @@ mod tests { let odd = [Some(Rect::new(0.0, 0.0, 10.0, 10.0)), Some(Rect::new(f64::NAN, 0.0, 10.0, 10.0)), Some(Rect::new(5.0, 5.0, 20.0, 20.0))]; assert_eq!(overlapping(&odd).unwrap(), vec![vec![0, 2], vec![1]]); } + + /// A filled rectangle, unstroked. + fn filled(id: u64, r: Rect) -> Node { + let look = vectorcraft_doc::Appearance::basic( + vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)), + vectorcraft_color::Paint::None, + 0.0, + ); + Node::path(vectorcraft_doc::NodeId(id), vectorcraft_geom::shapes::rectangle(r), look) + } + + /// The antialiasing margin goes around the outermost group once, not once per group. + #[test] + fn reach_adds_its_margin_once_however_deep_the_groups() { + let r = Rect::new(10.0, 20.0, 30.0, 50.0); + let inner = Node::group(vectorcraft_doc::NodeId(2), vec![Arc::new(filled(1, r))]); + let outer = Node::group(vectorcraft_doc::NodeId(3), vec![Arc::new(inner)]); + assert_eq!(painted(&outer), Some(r)); + assert_eq!(reach(&outer), Some(r.inflate(1.0, 1.0))); + assert_eq!(reach(&filled(1, r)), reach(&outer)); + } + + /// The group of each object, the topmost object of each group and whether it is see-through. + fn sets_of(groups: &[Vec], n: usize, see_through: &[bool]) -> (Vec, Vec, Vec) { + let mut of = vec![usize::MAX; n]; + for (g, ids) in groups.iter().enumerate() { + for &i in ids { + of[i] = g; + } + } + let top = groups.iter().map(|g| *g.last().unwrap()).collect(); + let seeded = groups.iter().map(|g| g.iter().any(|&i| see_through[i])).collect(); + (of, top, seeded) + } + + /// What [`flatten_groups`] must give: every object in one group, in paint order, the groups by + /// their first object; see-through objects with all that their reach touches; groups without + /// transparency of one object; no two objects that paint over each other the other way round + /// once each group takes its top's place; nothing within `fringe` of a member of a group under + /// that group's top. + fn check(reach: &[Option], painted: &[Option], see: &[bool], fringe: f64, groups: &[Vec], what: &str) { + let n = reach.len(); + let mut all: Vec = groups.iter().flatten().copied().collect(); + all.sort_unstable(); + assert_eq!(all, (0..n).collect::>(), "{what}: every object once"); + assert!(groups.iter().all(|g| g.is_sorted()) && groups.windows(2).all(|w| w[0][0] < w[1][0]), "{what}: in order"); + let (of, top, seeded) = sets_of(groups, n, see); + for (g, ids) in groups.iter().enumerate() { + assert!(seeded[g] || ids.len() == 1, "{what}: {ids:?} has no transparency"); + } + for i in 0..n { + for j in 0..n { + if i == j || of[i] == of[j] { + continue; + } + let (gi, gj) = (of[i], of[j]); + if let (Some(a), Some(b)) = (reach[i], reach[j]) { + assert!(!(touch(&a, &b) && (see[i] || see[j])), "{what}: {i} and {j} touch, one see-through"); + if seeded[gi] && touch(&a, &b) { + assert_eq!(i < j, top[gi] < top[gj], "{what}: {i} and {j} change places"); + } + } + let (Some(a), Some(b)) = (painted[i], painted[j]) else { continue }; + if seeded[gi] && fringe > 0.0 && touch(&a.inflate(fringe, fringe), &b) { + assert!(top[gj] > top[gi], "{what}: {j} under the image of {i}'s group"); + } + } + } + } + + /// Boxes of many sizes, some see-through, group as they must, with and without a fringe. + #[test] + fn flatten_groups_keep_stacking_order_and_leave_opaque_art_alone() { + let mut seed = 0x9e37_79b9_7f4a_7c15u64; + let mut next = |m: f64| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed % 1000) as f64 / 1000.0 * m + }; + for round in 0..60 { + let n = 4 + round; + let painted: Vec> = (0..n) + .map(|i| { + if i % 13 == 5 { + return None; + } + let (x, y) = (next(120.0).round(), next(120.0).round()); + Some(Rect::new(x, y, x + next(25.0).round(), y + next(25.0).round())) + }) + .collect(); + let reach: Vec> = painted.iter().map(|b| b.map(|b| b.inflate(1.0, 1.0))).collect(); + let see: Vec = (0..n).map(|_| next(1.0) < 0.15).collect(); + for fringe in [0.0, 0.7] { + let groups = flatten_groups(&reach, &painted, &see, fringe).unwrap(); + check(&reach, &painted, &see, fringe, &groups, &format!("round {round}, fringe {fringe}")); + } + } + } + + /// A row of opaque boxes whose reaches touch, with see-through ones at its ends and far from + /// it: only the boxes next to a see-through one join it. + #[test] + fn an_opaque_row_stays_apart_from_the_see_through_boxes_at_its_ends() { + let n = 200; + // 10 pt boxes 1.5 pt apart: their 1 pt reaches overlap. + let painted: Vec> = (0..n).map(|i| Some(Rect::new(11.5 * i as f64, 0.0, 11.5 * i as f64 + 10.0, 10.0))).collect(); + let reach: Vec> = painted.iter().map(|b| b.map(|b| b.inflate(1.0, 1.0))).collect(); + let mut see = vec![false; n]; + see[0] = true; + see[n - 1] = true; + let groups = flatten_groups(&reach, &painted, &see, 0.0).unwrap(); + check(&reach, &painted, &see, 0.0, &groups, "row"); + assert_eq!(groups[0], vec![0, 1]); + assert_eq!(groups.last(), Some(&vec![n - 2, n - 1])); + assert_eq!(groups.len(), n - 2); + // Without transparency every box is a group of its own. + assert_eq!(flatten_groups(&reach, &painted, &vec![false; n], 0.0).unwrap().len(), n); + } + + /// An opaque box between a member and its group's top that paints over the member joins, and + /// so on along a chain; one under the member stays, but for one within the fringe. + #[test] + fn boxes_the_group_would_cross_join_it() { + // In paint order: D under A, A, B over A, C over B, then the see-through T over A. + let d = Rect::new(40.0, 80.0, 65.0, 120.0); + let a = Rect::new(60.0, 50.0, 105.0, 90.0); + let b = Rect::new(20.0, 20.0, 65.0, 55.0); + let c = Rect::new(0.0, 0.0, 25.0, 25.0); + let t = Rect::new(100.0, 50.0, 140.0, 90.0); + let painted = [Some(d), Some(a), Some(b), Some(c), Some(t)]; + let reach: Vec> = painted.iter().map(|b| b.map(|b| b.inflate(1.0, 1.0))).collect(); + let see = [false, false, false, false, true]; + let groups = flatten_groups(&reach, &painted, &see, 0.0).unwrap(); + assert_eq!(groups, vec![vec![0], vec![1, 2, 3, 4]]); + // C away from B: it stays. + let far = [Some(d), Some(a), Some(b), Some(Rect::new(0.0, 0.0, 10.0, 10.0)), Some(t)]; + let reach_far: Vec> = far.iter().map(|b| b.map(|b| b.inflate(1.0, 1.0))).collect(); + assert_eq!(flatten_groups(&reach_far, &far, &see, 0.0).unwrap(), vec![vec![0], vec![1, 2, 4], vec![3]]); + // B 0.5 pt above A, not overlapping it: their antialiased edges can share pixels, so B joins. + let gap = [Some(d), Some(a), Some(Rect::new(20.0, 20.0, 65.0, 49.5)), Some(Rect::new(0.0, 0.0, 10.0, 10.0)), Some(t)]; + let reach_gap: Vec> = gap.iter().map(|b| b.map(|b| b.inflate(1.0, 1.0))).collect(); + assert_eq!(flatten_groups(&reach_gap, &gap, &see, 0.0).unwrap(), vec![vec![0], vec![1, 2, 4], vec![3]]); + // D a little left of A, under it: the fringe takes it in. + let near = [Some(Rect::new(40.0, 80.0, 59.8, 120.0)), Some(a), Some(b), Some(c), Some(t)]; + let reach_near: Vec> = near.iter().map(|b| b.map(|b| b.inflate(1.0, 1.0))).collect(); + assert_eq!(flatten_groups(&reach_near, &near, &see, 0.0).unwrap()[0], vec![0]); + assert_eq!(flatten_groups(&reach_near, &near, &see, 0.24).unwrap(), vec![vec![0, 1, 2, 3, 4]]); + } } diff --git a/crates/engine/src/cmd/flatten/apply.rs b/crates/engine/src/cmd/flatten/apply.rs index c1366dba3c..ef8425b362 100644 --- a/crates/engine/src/cmd/flatten/apply.rs +++ b/crates/engine/src/cmd/flatten/apply.rs @@ -1,4 +1,4 @@ -use super::analysis::{Scratch, has_effects, masked, outlined_text, overlapping, reach}; +use super::analysis::{Scratch, finite, flatten_groups, has_effects, masked, outlined_text, overlapping, painted, reach}; use super::compose::{Builder, CLEAR, Composer, Elem, Fill, Kind, Px, WHITE}; use super::options::FlattenOptions; use super::{ @@ -115,7 +115,7 @@ pub(super) fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions, render: let mut out = Plan { flat: vec![], kept: vec![], transparent: roots.iter().zip(&see_through).filter(|(_, t)| **t).map(|(id, _)| *id).collect() }; // Baking above stopped early when cancelled; each group is a place to stop (a plan changes nothing). vectorcraft_doc::cancel::check()?; - let mut queue = overlapping(&reaches)?; + let mut queue = groups(src, roots, &plain, &reaches, &see_through, o)?; queue.reverse(); while let Some(g) = queue.pop() { vectorcraft_doc::cancel::check()?; @@ -196,6 +196,46 @@ pub(super) fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions, render: Ok(out) } +/// `roots` of `src` (`plain`: as plain art; `reaches`: where each can paint, clipped) in the +/// groups that flatten together ([`flatten_groups`]), each in paint order, by their first object. +/// Objects that overlap (as [`overlapping`] groups them) stay in one group when one of them is +/// inside a clip group or clipping layer: [`inside_and_under`] and [`place_of`] then decide where +/// they go. +fn groups( + src: &Document, + roots: &[NodeId], + plain: &[Node], + reaches: &[Option], + see_through: &[bool], + o: &FlattenOptions, +) -> Result>> { + // An image clipped to its regions, opaque over white, reaches a pixel past them; a group's + // image is no larger than the art it overlaps, so its pixels are no wider than this. + let opaque_edge = o.no_transparency.is_some() && o.clip_complex_regions && !o.preserve_alpha; + let ppi = o.line_art_ppi.min(o.gradient_ppi).max(1.0); + let extent = |ids: &mut dyn Iterator| ids.filter_map(|i| reaches[i].filter(finite)).reduce(|a, b| a.union(b)).unwrap_or_default(); + // Where the widest such pixel is wider than the margins of two reaches, objects that close + // overlap too. + let widest = if opaque_edge { 1.0 / raster_scale(extent(&mut (0..reaches.len())), ppi) } else { 0.0 }; + let grow = ((widest - 2.0) / 2.0).max(0.0); + let near: Vec> = reaches.iter().map(|b| b.map(|b| b.inflate(grow, grow))).collect(); + let mut out = vec![]; + for g in overlapping(if grow > 0.0 { &near } else { reaches })? { + let clipped = || clips_count() && g.iter().any(|&i| !clip_ancestors(src, roots[i]).is_empty()); + if g.len() < 2 || !g.iter().any(|&i| see_through[i]) || clipped() { + out.push(g); + continue; + } + let fringe = if opaque_edge { 1.0 / raster_scale(extent(&mut g.iter().copied()), ppi) } else { 0.0 }; + let local = |v: &[Option]| g.iter().map(|&i| v[i]).collect::>(); + let painted: Vec> = g.iter().map(|&i| painted(&plain[i])).collect(); + let see: Vec = g.iter().map(|&i| see_through[i]).collect(); + out.extend(flatten_groups(&local(reaches), &painted, &see, fringe)?.into_iter().map(|l| l.into_iter().map(|k| g[k]).collect::>())); + } + out.sort_by_key(|g| g.first().copied()); + Ok(out) +} + /// `plain` (object `id` of `src` as plain art) inside a clip group for each clip group and /// clipping layer around `id`, innermost first, each clipped by that group's or layer's clipping /// path. `plain` itself when nothing around it clips. With `fills` (clipping paths, and an area), @@ -482,11 +522,7 @@ fn region_fill(elems: &[Elem], sources: &[usize], o: &FlattenOptions, cmyk: bool /// amounts (a CMYK TIFF), so its CMYK colours keep their numbers (#2160); other images are PNGs. /// Fails when the image can't be encoded. fn render_raster(doc: &Document, art: &[Node], rect: Rect, ppi: f64, clip: Option, o: &FlattenOptions) -> Result { - let mut scale = ppi / 72.0; - scale = scale.min((MAX_PIXELS / (rect.width() * rect.height()).max(1.0)).sqrt()); - // No side longer than the renderer draws: the image grows by a pixel on each side and is - // rounded up to whole pixels below. - scale = scale.min((f64::from(vectorcraft_render::MAX_RASTER_SIDE) - 4.0) / rect.width().max(rect.height()).max(1.0)); + let scale = raster_scale(rect, ppi); let px = 1.0 / scale; let rect = rect.inflate(px, px); let clip = clip.map(|c| Some(po::offset_path(&c, px, po::Join::Miter, 4.0)).filter(|g| !g.is_empty()).unwrap_or(c)); @@ -559,6 +595,14 @@ fn render_raster(doc: &Document, art: &[Node], rect: Rect, ppi: f64, clip: Optio Ok(Raster { blob: ImageBlob::new("image/png", png), width: img.width, height: img.height, xf, clip }) } +/// Pixels per point of an image of `rect` at `ppi`: lower when the image would be larger than +/// [`MAX_PIXELS`], or have a side longer than the renderer draws (the image grows by a pixel on +/// each side and is rounded up to whole pixels). +fn raster_scale(rect: Rect, ppi: f64) -> f64 { + let scale = (ppi / 72.0).min((MAX_PIXELS / (rect.width() * rect.height()).max(1.0)).sqrt()); + scale.min((f64::from(vectorcraft_render::MAX_RASTER_SIDE) - 4.0) / rect.width().max(rect.height()).max(1.0)) +} + /// `n` at full opacity with Normal blending throughout (objects, fills and strokes). pub(super) fn opaque(n: &mut Node) { n.opacity = 1.0; @@ -877,4 +921,42 @@ mod tests { assert_eq!(flat.parent_of(g), Some(layer)); assert_eq!(flat.children(Some(layer)).and_then(|c| c.last()).map(|c| c.id), Some(g)); } + + /// In a file without transparency with Clip Complex Regions on, art under a group's opaque + /// image within one of its pixels joins the group, also where a long image is made at a lower + /// resolution and its pixels are wider than at the preset's. + #[test] + fn art_under_the_edge_of_a_long_opaque_image_joins_its_group() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 31000, "height": 100})).unwrap(); + let rect = |s: &mut Session, x: f64, w: f64| { + let id = s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": w, "height": 10})).unwrap()["id"].as_u64().unwrap(); + s.execute("paint.setStroke", &json!({"none": true, "ids": [id]})).unwrap(); + NodeId(id) + }; + // 0.5 pt left of A: wider than a pixel at 300 ppi, narrower than one of a 30,000 pt image. + let d = rect(&mut s, 40.0, 19.5); + let a = rect(&mut s, 60.0, 40.0); + let t = rect(&mut s, 95.0, 30_000.0); + s.execute("transparency.set", &json!({"ids": [t.0], "opacity": 50})).unwrap(); + let o = FlattenOptions { no_transparency: Some(Default::default()), clip_complex_regions: true, ..FlattenOptions::default() }; + let doc = &s.doc().unwrap().doc; + let long = plan(doc, &[d, a, t], &o, false).unwrap(); + assert!(long.kept.is_empty(), "{:?}", long.kept); + assert_eq!(long.flat.iter().map(|f| f.roots.clone()).collect::>(), vec![vec![d, a, t]]); + // Short, the image has 300 ppi pixels: D stays. + let short = rect(&mut s, 95.0, 40.0); + s.execute("transparency.set", &json!({"ids": [short.0], "opacity": 50})).unwrap(); + let doc = &s.doc().unwrap().doc; + assert_eq!(plan(doc, &[d, a, short], &o, false).unwrap().kept, vec![d]); + // 100,000 pt long, its pixels are over 3 pt wide: art 3 pt away, whose reach doesn't touch + // A's, joins too. + let far = rect(&mut s, 40.0, 17.0); + let longer = rect(&mut s, 95.0, 100_000.0); + s.execute("transparency.set", &json!({"ids": [longer.0], "opacity": 50})).unwrap(); + let doc = &s.doc().unwrap().doc; + let p = plan(doc, &[far, a, longer], &o, false).unwrap(); + assert!(p.kept.is_empty(), "{:?}", p.kept); + assert_eq!(p.flat.iter().map(|f| f.roots.clone()).collect::>(), vec![vec![far, a, longer]]); + } } diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index 9676c3b825..9003e325a9 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -23,7 +23,7 @@ pub fn specs() -> Vec { "Gradient", ["Window", "Gradient"], None, - "{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|conic|freeform (conic, also accepted as angle or conical, sweeps the stops once around the center, clockwise from the vector, with a seam where the last stop meets the first; freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual (how the colours between the stops mix: in display RGB, or perceptually (OKLab) so the steps look even; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear, radial or conic, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg counter-clockwise (0: the gradient runs left to right; radial and conic: the vector turns about the center), aspect?: % (radial and conic; a negative one mirrors a conic's sweep and is an error for the other kinds), reverse?: bool, dither?: bool (fills only: a linear, radial or conic gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient", + "{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical, sweeps the stops once around the center, clockwise from the vector, with a seam where the last stop meets the first; diamond runs the stops from the center out to a square with a corner at the end of the vector; freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual (how the colours between the stops mix: in display RGB, or perceptually (OKLab) so the steps look even; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear, radial, conic or diamond, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg counter-clockwise (0: the gradient runs left to right; radial, conic and diamond: the vector turns about the center), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep and is an error for the other kinds), reverse?: bool, dither?: bool (fills only: a linear, radial, conic or diamond gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient", has_doc, edit_gradient ), @@ -32,7 +32,7 @@ pub fn specs() -> Vec { "Gradient Vector", [], None, - "{start?: [x,y], end?: [x,y] (document coordinates; both or neither: omitted, the vector stays), aspect?: % (radial and conic: the extent ellipse's height / width, a negative one mirroring a conic's sweep and an error for the other kinds; default: kept), focal?: [x,y] (document coordinates) | null (radial: the focal point, where the first stop sits, pulled inside the extent ellipse; null centres it; default: it keeps its place in the ellipse; an error on a conic gradient), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (alias: index; omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\", index?: appearance item index (as item)} set the gradient vector, aspect ratio and focal point (solid paints become the default gradient; type objects set it on their runs, in text space)", + "{start?: [x,y], end?: [x,y] (document coordinates; both or neither: omitted, the vector stays), aspect?: % (radial, conic and diamond: the extent's height / width across the vector, a negative one mirroring a conic's sweep and an error for the other kinds; default: kept), focal?: [x,y] (document coordinates) | null (radial: the focal point, where the first stop sits, pulled inside the extent ellipse; null centres it; default: it keeps its place in the ellipse; an error on a conic or diamond gradient), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (alias: index; omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\", index?: appearance item index (as item)} set the gradient vector, aspect ratio and focal point (solid paints become the default gradient; type objects set it on their runs, in text space)", has_doc, set_gradient_geom ), @@ -118,7 +118,7 @@ type Parsed = std::result::Result; /// Parse a kind name. fn parse_kind(k: &str) -> Parsed { - GradientKind::parse(k).ok_or_else(|| format!("unknown gradient kind `{k}` (linear, radial, conic, freeform)")) + GradientKind::parse(k).ok_or_else(|| format!("unknown gradient kind `{k}` (linear, radial, conic, diamond, freeform)")) } /// The `method` param of `p`, if given: how the colours between the stops mix. @@ -275,7 +275,7 @@ pub(crate) fn parse_gradient(g: &Value) -> Parsed { gp.swatch = str_param(g, "swatch").map(str::to_string); let geom = GeomEdit::parse(g)?; let aspect = geom.aspect.map(|a| aspect_for(kind, a)).transpose()?; - if kind == GradientKind::Conic { + if matches!(kind, GradientKind::Conic | GradientKind::Diamond) { no_focal(&geom)?; } if let Some((start, end)) = geom.vector { @@ -315,7 +315,7 @@ fn aspect_for(kind: GradientKind, a: f64) -> Parsed { Ok(a) } -/// An error when `e` gives a focal point: a conic gradient has none (`null` is allowed). +/// An error when `e` gives a focal point: a conic or diamond gradient has none (`null` is allowed). fn no_focal(e: &GeomEdit) -> Parsed<()> { match e.focal { Some(Some(_)) => Err("`focal` applies to radial gradients".into()), @@ -645,7 +645,7 @@ impl GeomEdit { /// the paint's space (text space for type runs) to the document and `bounds` (in that space) fits /// an unplaced gradient, so what the document shows carries over where `e` keeps it. An error /// when `e` gives what the gradient's kind can't take (a negative aspect other than a conic's, or -/// a conic's focal point). +/// a conic's or diamond's focal point). fn vector_paint( cur: &Paint, e: &GeomEdit, @@ -656,7 +656,7 @@ fn vector_paint( let mut gp = gradient_for_edit(cur, model); let kind = gp.gradient.kind; let edit_aspect = e.aspect.map(|a| aspect_for(kind, a)).transpose()?; - if kind == GradientKind::Conic { + if matches!(kind, GradientKind::Conic | GradientKind::Diamond) { no_focal(e)?; } let Some(from_doc) = invert(to_doc) else { return Ok(None) }; diff --git a/crates/engine/src/cmd/newdoc.rs b/crates/engine/src/cmd/newdoc.rs index 20dc187ab5..bd15d748ca 100644 --- a/crates/engine/src/cmd/newdoc.rs +++ b/crates/engine/src/cmd/newdoc.rs @@ -7,7 +7,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{Artboard, Background, ColorMode, Document, Unit}; use vectorcraft_geom::{Point, Rect}; -use super::{CommandSpec, Result, Session, always, bad, clock_date, cmd, date_param, str_param}; +use super::{CommandSpec, Result, Session, always, bad, clock_date, cmd, date_param, str_param, switch_or}; pub fn specs() -> Vec { vec![ @@ -16,7 +16,7 @@ pub fn specs() -> Vec { "New Document Presets", [], None, - "{category?: \"Recent\"|\"Saved\"|\"Mobile\"|\"Web\"|\"Print\"|\"Film & Video\"|\"Art & Illustration\"|\"Branding\"|\"Social\"} the New Document presets by category (Recent: the last sizes used; Saved: the user's presets) → {categories: [{name, presets: [{name, size: \"1920 × 1080 px\", width: pt, height: pt, units, orientation, artboards, artboardLayout: {layout, columns, spacing, rightToLeft}, bleed, backgroundContents, colorMode, rasterEffectsPpi, previewMode}]}]}; file.new {preset: name} starts from one", + "{category?: \"Recent\"|\"Saved\"|\"Mobile\"|\"Web\"|\"Print\"|\"Film & Video\"|\"Art & Illustration\"|\"Branding\"|\"Social\"} the New Document presets by category (Recent: the last sizes used; Saved: the user's presets) → {categories: [{name, presets: [{name, size: \"1920 × 1080 px\", width: pt, height: pt, units, orientation, artboards, artboardLayout: {layout, columns, spacing, rightToLeft}, bleed, backgroundContents, colorMode, rasterEffectsPpi, previewMode, alignNewObjectsToPixelGrid (on in the Mobile and Web presets)}]}]}; file.new {preset: name} starts from one", always, list ), @@ -144,6 +144,10 @@ pub struct DocSettings { #[serde(deserialize_with = "de_ppi")] pub raster_effects_ppi: f64, pub preview_mode: PreviewMode, + /// Align New Objects to Pixel Grid ([`vectorcraft_doc::DocSetup::align_new_objects_to_pixel_grid`]): + /// on in the Mobile and Web presets, off in the others and in a saved or Recent preset from + /// before it. + pub align_new_objects_to_pixel_grid: bool, } impl Default for DocSettings { @@ -163,6 +167,7 @@ impl Default for DocSettings { color_mode: ColorMode::Rgb, raster_effects_ppi: 72.0, preview_mode: PreviewMode::Default, + align_new_objects_to_pixel_grid: false, } } } @@ -211,6 +216,7 @@ impl DocSettings { "colorMode": color_mode_id(self.color_mode), "rasterEffectsPpi": self.raster_effects_ppi, "previewMode": self.preview_mode.id(), + "alignNewObjectsToPixelGrid": self.align_new_objects_to_pixel_grid, }) } @@ -273,6 +279,7 @@ impl DocSettings { if let Some(m) = p.get("previewMode").filter(|v| !v.is_null()) { self.preview_mode = m.as_str().and_then(PreviewMode::parse).ok_or_else(|| bad(cmd, "previewMode must be default, pixel or overprint"))?; } + self.align_new_objects_to_pixel_grid = switch_or(p, "alignNewObjectsToPixelGrid", self.align_new_objects_to_pixel_grid, cmd)?; let extent = self.boards().iter().fold(Rect::ZERO, |a, r| a.union(*r)); if extent.width().max(extent.height()) + self.bleed.iter().fold(0.0f64, |a, b| a.max(*b)) * 2.0 > crate::MAX_COORD { return Err(bad(cmd, "the artboards don't fit on the canvas: fewer artboards, less spacing or a smaller size")); @@ -330,6 +337,7 @@ impl DocSettings { d.raster_effects_ppi = self.raster_effects_ppi; d.setup.bleed = self.bleed; d.setup.background = self.background; + d.setup.align_new_objects_to_pixel_grid = self.align_new_objects_to_pixel_grid; d.artboards = self .boards() .into_iter() @@ -543,6 +551,10 @@ pub const CATEGORIES: &[Category] = &[ ), ]; +/// The built-in categories whose presets start with Align New Objects to Pixel Grid on +/// (). +const PIXEL_ALIGNED_CATEGORIES: [&str; 2] = ["Mobile", "Web"]; + /// The category tabs in order: Recent, Saved, then the built-in categories. pub fn category_names() -> impl Iterator { ["Recent", "Saved"].into_iter().chain(CATEGORIES.iter().map(|c| c.0)) @@ -551,7 +563,8 @@ pub fn category_names() -> impl Iterator { /// The built-in presets of one category: screen sizes in pixels, the others in `general` /// (Preferences ▸ Units ▸ General). fn builtin(c: &Category, general: Unit) -> impl Iterator + '_ { - let (_, color_mode, ppi, presets) = *c; + let (category, color_mode, ppi, presets) = *c; + let align = PIXEL_ALIGNED_CATEGORIES.contains(&category); presets.iter().map(move |&(name, w, h, units)| DocSettings { name: name.into(), width: units.to_pt(w), @@ -559,6 +572,7 @@ fn builtin(c: &Category, general: Unit) -> impl Iterator + ' units: if units == Unit::Pixels { units } else { general }, color_mode, raster_effects_ppi: ppi, + align_new_objects_to_pixel_grid: align, ..DocSettings::default() }) } diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 64157a20fa..5eee2d3fce 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -37,7 +37,8 @@ pub(crate) use bounds::{bounds_rewrite, object_bounds_transform}; pub use commands::specs; pub(super) use compound::{as_clipping_path, make_clipping_path, release_clip}; pub(crate) use pixelgrid::{ - can_align, keep_aligned_in, keep_compound_aligned, keep_pixel_aligned, refresh as refresh_pixel_aligned, resnap, snap_if_aligned, + align_new_object, can_align, keep_aligned_in, keep_compound_aligned, keep_pixel_aligned, refresh as refresh_pixel_aligned, resnap, + snap_if_aligned, }; pub(super) use shape::recut; pub(crate) use transform::{apply_transform, preview_copies, scaling, transform_node, transform_patterns, transforms_objects}; diff --git a/crates/engine/src/cmd/object/pixelgrid.rs b/crates/engine/src/cmd/object/pixelgrid.rs index 6441fda0c3..64d47ca0a9 100644 --- a/crates/engine/src/cmd/object/pixelgrid.rs +++ b/crates/engine/src/cmd/object/pixelgrid.rs @@ -22,6 +22,16 @@ pub(crate) fn can_align(n: &Node) -> bool { matches!(n.kind, NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. }) } +/// For an object being drawn into `d`: when the document's Align New Objects to Pixel Grid is on +/// and [`can_align`] accepts `n`, turn `n`'s pixel-aligned property on. The edit that adds it snaps +/// it to the pixel grid ([`refresh`]). Pasted, duplicated, placed and imported objects do not come +/// through here. +pub(crate) fn align_new_object(d: &Document, n: &mut Node) { + if d.setup.align_new_objects_to_pixel_grid && can_align(n) { + n.extras_mut().pixel_aligned = true; + } +} + /// `object.setPixelAligned`: turn the pixel-aligned property of the objects given (or the selected /// objects) on or off. Turning it on snaps them to the pixel grid at once. Every object must be /// one [`can_align`] accepts, else nothing changes. When every object already has the value, the diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 93c4cbf2c7..b199f9a356 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -21,7 +21,7 @@ pub fn specs() -> Vec { "Fill", [], None, - "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector), method?: classic|perceptual (as paint.editGradient; default classic), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial and conic; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic), dither?: bool (linear, radial and conic fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial or conic `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern) → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}", + "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual (as paint.editGradient; default classic), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern) → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}", has_doc, |s, p| set_paint(s, p, true) ), @@ -641,7 +641,7 @@ fn paint_new_art(s: &mut Session, paint: &Paint, fill: bool) { } } -/// Does `p` ask for `span: "selection"` (#2127)? Only a linear, radial or conic gradient the targets +/// Does `p` ask for `span: "selection"` (#2127)? Only a linear, radial, conic or diamond gradient the targets /// would each fit to their own bounds can span them: one with its own vector (`start`/`end`), /// a freeform one or a gradient swatch already has its placement. fn spans_targets(p: &Value, paint: &Paint) -> std::result::Result { @@ -656,7 +656,7 @@ fn spans_targets(p: &Value, paint: &Paint) -> std::result::Result return Err("span: \"selection\" places a `gradient`, not a swatch".into()); } if g.gradient.kind == vectorcraft_color::GradientKind::Freeform || g.freeform.is_some() { - return Err("span: \"selection\" places a linear, radial or conic gradient, not a freeform one".into()); + return Err("span: \"selection\" places a linear, radial, conic or diamond gradient, not a freeform one".into()); } if g.geom.is_some() { return Err( diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index 300ca2d872..50f9ee48e8 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -52,7 +52,7 @@ use vectorcraft_plugins::{Budget, Command, Kind, Plugin, effect, export, objects use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, cmd, has_doc, ids_param, str_param, switch_or}; -use vectorcraft_plugins::permissions::{self, Grant, Permission}; +use vectorcraft_plugins::permissions::{self, Grant, GrantToken, Permission}; /// What a plug-in error looks like to the user. A run the caller cancelled changed nothing. fn err(e: vectorcraft_plugins::Error) -> EngineError { @@ -750,7 +750,7 @@ fn install(s: &mut Session, p: &Value) -> Result { let holds_new = |s: &Session| s.stored_plugin_bytes(plugin.id()).is_ok_and(|held| held.as_deref() == Some(&bytes[..])); if m.asks_permissions() { let g = Grant { id: m.id.clone(), hash: plugin.hash().to_string(), permissions: wanted }; - registry::grant(g.clone()); + registry::grant(GrantToken::after_engine_approval(g.clone())); if stored && !s.store_grant(&m.id, Some(g)) { stored = !s.put_back_plugin(plugin.id(), previous.ok().flatten().as_deref()) && holds_new(s); } @@ -787,7 +787,8 @@ fn install(s: &mut Session, p: &Value) -> Result { if changed { s.store_disabled_plugins(); } - let mut v = describe_installed(®istry::install(plugin)); + let installed = registry::install(plugin).map_err(err)?; + let mut v = describe_installed(&installed); if !unknown.is_empty() { v["unknownCommands"] = json!(unknown); } @@ -886,7 +887,7 @@ fn load_folder(s: &Session, folder: &str) -> Value { None if crate::file_access::current().is_none() => Ok(()), None => Err(format!("automation path rejected: {} isn't valid text", f.display())), }; - match registry::load_folder_where(std::path::Path::new(folder), allow, s.plugin_approver()) { + match registry::load_folder_where(std::path::Path::new(folder), allow, s.plugin_folder_approver()) { Ok(r) => { for id in &r.loaded { s.attach_plugin_storage(id); diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs index f3bb512008..c809d58090 100644 --- a/crates/engine/src/cmd/swatch.rs +++ b/crates/engine/src/cmd/swatch.rs @@ -404,6 +404,7 @@ fn gradient_key(g: &Gradient) -> Option> { GradientKind::Radial => 1, GradientKind::Freeform => 2, GradientKind::Conic => 3, + GradientKind::Diamond => 4, }; let method = match g.method { GradientMethod::Classic => 0, diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index 03681c556a..faaaf2145d 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -69,6 +69,15 @@ pub fn specs() -> Vec { has_dynamic_type, dynamic_text ), + cmd!( + "type.dynamicTextPreset", + "Dynamic Text Presets", + ["Type", "Dynamic Text Presets"], + None, + "{preset: \"circle\"|\"arch\"|\"bow\", position?: \"above\"|\"center\"|\"below\", flip?: bool, ids?|id?} put the selected point type and type on a path on a new path of the preset's shape with Dynamic Text on, so the text is sized to fill it (see type.dynamicText). Circle: a circle as wide as the text, the text running clockwise round all of it from its bottom; Arch: an arc of a circle bulging upward over 120°, as wide as the text; Bow: the same arc bulging downward. The width is, for point type, the length on the page of the longer side of the text's bounds, and for type on a path the longer side of its path's bounds on the page (so the same preset again leaves it as it is); the new path is centered where they were, and the shape is upright on the page when the object is rotated or scaled. position (omitted: above) sets Type on a Path Options › Align to Path: above stands the type on the path (descender), center centers it on the path (center), below hangs it from the path (ascender); flip true then turns the text to the other side of its path, running the other way. Type on a Path Options › effect and spacing stay. Area type in the selection is left as it is; a selection holding type on a path in a thread is refused. When nothing changes, no undo step is recorded → {ids, fitScale (the first object's factor)}", + has_preset_type, + dynamic_text_preset + ), cmd!( "type.fillPlaceholder", "Fill with Placeholder Text", @@ -648,6 +657,196 @@ pub(crate) fn bake_dynamic_text(t: &mut TextObject) -> bool { true } +/// The shapes of `type.dynamicTextPreset`. +const PRESETS: [&str; 3] = ["circle", "arch", "bow"]; + +/// Point type and type on a path among `ids` and their descendants: the type +/// `type.dynamicTextPreset` puts on a path. +fn preset_ids(d: &Document, ids: &[NodeId]) -> Vec { + text_ids(d, ids) + .into_iter() + .filter(|id| matches!(d.node(*id).map(|n| &n.kind), Some(NodeKind::Text(t)) if matches!(t.kind, TextKind::Point | TextKind::OnPath { .. }))) + .collect() +} + +/// `type.dynamicTextPreset` is enabled for a selection holding point type or type on a path, and +/// no type on a path in a thread. +fn has_preset_type(s: &Session) -> std::result::Result<(), String> { + has_selection(s)?; + let st = s.active().ok_or("no document open")?; + let d = &st.doc; + let ids = preset_ids(d, &st.selection.objects); + if ids.is_empty() { + return Err("select point type or type on a path".into()); + } + let frames = threaded_ids(d, &ids); + if !frames.is_empty() { + return Err(format!("type on a path in a thread cannot use Dynamic Text: {frames}")); + } + Ok(()) +} + +/// The path of Dynamic Text preset `preset` (one of [`PRESETS`]) `w` wide, centered on `c`, with +/// the start bracket the text starts at. Circle: a circle `w` across, run clockwise from its +/// bottom; Arch and Bow: a 120° arc of a circle whose chord is `w`, bulging up or down, run from +/// left to right. +fn preset_path(preset: &str, c: vectorcraft_geom::Point, w: f64) -> (vectorcraft_geom::PathData, f64) { + let h = w / 2.0; + if preset == "circle" { + // `shapes::ellipse` runs clockwise on screen from its left: its bottom is 3/4 of the way. + return (shapes::ellipse(Rect::new(c.x - h, c.y - h, c.x + h, c.y + h)), 0.75); + } + // A chord of `w` under 120°: radius w/√3, and the arc rises a half radius above its chord. + let r = w / 3f64.sqrt(); + let arch = preset == "arch"; + let cy = if arch { c.y - r / 4.0 + r } else { c.y + r / 4.0 - r }; + let circle = Rect::new(c.x - r, cy - r, c.x + r, cy + r); + // `shapes::ellipse_arc` runs counterclockwise on screen: right to left over the top. + let mut path = if arch { + shapes::ellipse_arc(circle, 30.0, 150.0, shapes::EllipseMode::Arc) + } else { + shapes::ellipse_arc(circle, 210.0, 330.0, shapes::EllipseMode::Arc) + }; + if arch { + path.reverse(); + } + (path, 0.0) +} + +/// `t` (point type or type on a path) on the path of `preset` with Dynamic Text on, Align to Path +/// `align`, and flipped when `flip`. The shape is made upright on the page and mapped back through +/// the object's transform, so a rotated or scaled object still shows a circle, an arch or a bow. +/// Point type: the path is as wide as the longer side of the text's layout bounds is long on the +/// page, centered on them; with no layout area (empty text), four ems of its first character +/// wide, centered on its estimated bounds. Type on a path: as wide as the longer side of its +/// path's bounds on the page, centered on them, so the same preset again gives the same path. +/// When the object's transform can't be undone, or undoing it gives a path that isn't finite, the +/// shape is made in the text's own space. +fn with_preset(t: &TextObject, preset: &str, align: vectorcraft_doc::PathAlign, flip: bool) -> TextObject { + let invertible = t.xf.is_finite() && t.xf.determinant() != 0.0 && t.xf.inverse().is_finite(); + // The transform the shape was made upright through, with the shape. + let ((path, start, w), xf) = invertible + .then(|| preset_shape(t, preset, t.xf).map(|s| (s, t.xf))) + .flatten() + .or_else(|| preset_shape(t, preset, Affine::IDENTITY).map(|s| (s, Affine::IDENTITY))) + .unwrap_or_else(|| { + let (path, start) = preset_path(preset, vectorcraft_geom::Point::ZERO, 48.0); + ((path, start, 48.0), Affine::IDENTITY) + }); + let mut out = t.clone(); + out.kind = TextKind::OnPath { path, start, end: None }; + out.path_align = align; + out.dynamic_text = true; + if flip { + out.flip_on_path(); + } + // The same preset again measures the path it made, which can differ in the last bits: a path + // within a billionth of its width of the old one on the page keeps the old one, so nothing + // changes. + if let (TextKind::OnPath { path: old, .. }, TextKind::OnPath { path: new, .. }) = (&t.kind, &mut out.kind) + && same_path(&old.transformed(xf), &new.transformed(xf), w * 1e-9) + { + *new = old.clone(); + } + refresh_bounds(&mut out); + out +} + +/// The path of `preset` for `t` made upright through transform `xf` (see [`with_preset`]), in the +/// text's space, with its start bracket and its width on the page; None when the path isn't +/// finite. +fn preset_shape(t: &TextObject, preset: &str, xf: Affine) -> Option<(vectorcraft_geom::PathData, f64, f64)> { + // How long `(x, y)` in the text's space is on the page. + let on_page = |x: f64, y: f64| (xf * vectorcraft_geom::Point::new(x, y) - xf * vectorcraft_geom::Point::ZERO).hypot(); + // The center on the page, and the width measured (0 when nothing can be measured). + let (c, longer) = match &t.kind { + TextKind::OnPath { path, .. } => path + .transformed(xf) + .bounds() + .filter(|b| b.is_finite()) + .map_or((xf * vectorcraft_geom::Point::ZERO, 0.0), |b| (b.center(), b.width().max(b.height()))), + _ => { + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).bounds; + match Some(lay).filter(|b| b.area() > 0.0 && b.is_finite()) { + Some(b) => (xf * b.center(), on_page(b.width(), 0.0).max(on_page(0.0, b.height()))), + None => (xf * Some(t.estimate_bounds()).filter(|b| b.is_finite()).map_or(vectorcraft_geom::Point::ZERO, |b| b.center()), 0.0), + } + } + }; + // Without a measured width: four ems of the first character, else 48 pt. + let size = t.first_style().size; + let w = if longer.is_finite() && longer > 1e-6 { + longer + } else if size.is_finite() && size > 0.0 && on_page(size * 4.0, 0.0) > 1e-6 { + on_page(size * 4.0, 0.0) + } else { + 48.0 + }; + let (path, start) = preset_path(preset, c, w); + let path = path.transformed(xf.inverse()); + let finite = path.subpaths.iter().flat_map(|s| &s.anchors).all(|a| a.p.is_finite() && a.h_in.is_finite() && a.h_out.is_finite()); + (finite && w.is_finite()).then_some((path, start, w)) +} + +/// Whether paths `a` and `b` have the same subpaths, closed alike, with every anchor and handle +/// within `eps` of each other. +fn same_path(a: &vectorcraft_geom::PathData, b: &vectorcraft_geom::PathData, eps: f64) -> bool { + let near = |p: vectorcraft_geom::Point, q: vectorcraft_geom::Point| (p - q).hypot() <= eps; + a.subpaths.len() == b.subpaths.len() + && a.subpaths.iter().zip(&b.subpaths).all(|(s, t)| { + s.closed == t.closed + && s.anchors.len() == t.anchors.len() + && s.anchors.iter().zip(&t.anchors).all(|(x, y)| x.kind == y.kind && near(x.p, y.p) && near(x.h_in, y.h_in) && near(x.h_out, y.h_out)) + }) +} + +/// Type › Dynamic Text Presets: put the selected point type and type on a path on a new path of +/// the preset's shape with Dynamic Text on, in one undo step, and none when nothing changes. +fn dynamic_text_preset(s: &mut Session, p: &Value) -> Result { + const C: &str = "type.dynamicTextPreset"; + let key = "preset"; + let preset = choice(p, C, key, &PRESETS)?.ok_or_else(|| bad(C, format!("`{key}` must be one of {}", PRESETS.join(", "))))?; + let align = match choice(p, C, "position", &["above", "center", "below"])? { + Some("center") => vectorcraft_doc::PathAlign::Center, + Some("below") => vectorcraft_doc::PathAlign::Ascender, + _ => vectorcraft_doc::PathAlign::Descender, + }; + let flip = switch_or(p, "flip", false, C)?; + let (ids, changed) = { + let d = &s.doc()?.doc; + let ids = preset_ids(d, &targets(s, p)?); + if ids.is_empty() { + return Err(bad(C, "select point type or type on a path")); + } + let frames = threaded_ids(d, &ids); + if !frames.is_empty() { + return Err(bad(C, format!("type on a path in a thread cannot use Dynamic Text: {frames}"))); + } + let changed: Vec<(NodeId, TextObject)> = ids + .iter() + .filter_map(|id| match d.node(*id).map(|n| &n.kind) { + Some(NodeKind::Text(t)) => Some((*id, with_preset(t, preset, align, flip))).filter(|(_, new)| new != &**t), + _ => None, + }) + .collect(); + (ids, changed) + }; + if !changed.is_empty() { + s.edit("Dynamic Text", |d, _| { + for (id, new) in &changed { + if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) { + **t = new.clone(); + } + } + Ok(()) + })?; + } + let d = &s.doc()?.doc; + let fit = + ids.first().and_then(|id| path_text(d.node(*id))).map_or(1.0, |t| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).fit_scale); + Ok(json!({"ids": ids.iter().map(|i| i.0).collect::>(), "fitScale": fit})) +} + // ---------- placeholder / insert ---------- /// VectorCraft's own placeholder copy (not the classical Latin text). diff --git a/crates/engine/src/store.rs b/crates/engine/src/store.rs index 531bbbb48b..a56b29d9db 100644 --- a/crates/engine/src/store.rs +++ b/crates/engine/src/store.rs @@ -39,6 +39,7 @@ use std::sync::{Arc, Mutex, MutexGuard}; use std::time::SystemTime; use vectorcraft_pdf::{PdfPreset, builtin_preset}; +use vectorcraft_plugins::permissions::GrantToken; use vectorcraft_plugins::safe_mode; use vectorcraft_plugins::storage::{self, Meter, Storage, StorageError}; @@ -392,9 +393,11 @@ impl Session { match grants.get(&m.id).filter(|g| grant_covers(g, &plugin)) { Some(g) => { asks_nothing.remove(&m.id); - vectorcraft_plugins::registry::grant(g.clone()); + vectorcraft_plugins::registry::grant(GrantToken::from_engine_grants_file(g.clone())); vectorcraft_plugins::registry::set_storage(&m.id, plugin_storage(Some(&dir), &m.id)); - vectorcraft_plugins::registry::install(plugin.with_source(path.display().to_string())); + if let Err(e) = vectorcraft_plugins::registry::install(plugin.with_source(path.display().to_string())) { + log::warn!("skipping the stored plug-in {}: {e}", path.display()); + } } None => { log::warn!("skipping the stored plug-in {}: the user hasn't granted this module the permissions it lists", path.display()) @@ -404,7 +407,9 @@ impl Session { Ok(plugin) => { asks_nothing.insert(plugin.id().to_string()); vectorcraft_plugins::registry::set_storage(plugin.id(), plugin_storage(Some(&dir), plugin.id())); - vectorcraft_plugins::registry::install(plugin.with_source(path.display().to_string())); + if let Err(e) = vectorcraft_plugins::registry::install(plugin.with_source(path.display().to_string())) { + log::warn!("skipping the stored plug-in {}: {e}", path.display()); + } } Err(e) => log::warn!("skipping the stored plug-in {}: {e}", path.display()), } @@ -478,6 +483,27 @@ impl Session { } } + /// [`Session::plugin_approver`] for a folder load: a module the store's grants approve gets + /// that grant recorded for the process too, since the registry installs only a module that a + /// recorded grant covers ([`vectorcraft_plugins::registry::install`]). + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn plugin_folder_approver(&self) -> impl Fn(&vectorcraft_plugins::Plugin) -> bool + use<> { + let grants = self.plugin_dir.as_deref().map(|dir| self.counted_grants(dir)); + move |plugin| { + let m = plugin.manifest(); + match &grants { + Some(grants) if m.asks_permissions() => match grants.get(&m.id).filter(|g| grant_covers(g, plugin)) { + Some(g) => { + vectorcraft_plugins::registry::grant(GrantToken::from_engine_grants_file(g.clone())); + true + } + None => false, + }, + _ => vectorcraft_plugins::registry::approved(plugin), + } + } + } + /// The grants kept in the plug-in store `dir` ([`stored_grants`]) without those this session /// doesn't count ([`Session::ignore_stored_grant`]). fn counted_grants(&self, dir: &Path) -> BTreeMap { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f5bbd54220..91c95cb630 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -161,6 +161,7 @@ mod script_include; mod script_io; mod script_run; mod scripts; +mod select_effects; mod select_ids_first; mod shaper; mod shapetools; diff --git a/crates/engine/src/tests/dynamic_text.rs b/crates/engine/src/tests/dynamic_text.rs index 3512db9c59..ae1a72fa08 100644 --- a/crates/engine/src/tests/dynamic_text.rs +++ b/crates/engine/src/tests/dynamic_text.rs @@ -234,6 +234,200 @@ fn svg_export_writes_the_drawn_size() { assert!(!sizes.iter().any(|v| (v - 12.0).abs() < 1e-3), "the stored size is not written: {sizes:?}"); } +/// 12 pt point type at (100, 100), selected, with its layout bounds. +fn point(s: &mut Session, content: &str) -> (NodeId, vectorcraft_geom::Rect) { + let r = s.execute("text.create", &json!({"x": 100, "y": 100, "text": content, "size": 12})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + let b = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(s, id)).bounds; + (id, b) +} + +/// Type on a path's path, start bracket and end bracket. +fn on_path(t: &TextObject) -> (vectorcraft_geom::PathData, f64, Option) { + match &t.kind { + vectorcraft_doc::TextKind::OnPath { path, start, end } => (path.clone(), *start, *end), + k => panic!("not type on a path: {k:?}"), + } +} + +fn close(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-6 +} + +#[test] +fn presets_put_point_type_on_a_fitted_circle_arch_or_bow() { + let mut s = session(); + let (id, b) = point(&mut s, "Round and round"); + let w = b.width().max(b.height()); + let before = undo_len(&s); + let r = s.execute("type.dynamicTextPreset", &json!({"preset": "circle"})).unwrap(); + assert_eq!(r["ids"], json!([id.0])); + assert_eq!(undo_len(&s), before + 1); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Dynamic Text"); + let t = text(&s, id); + assert!(t.dynamic_text); + assert_eq!(t.path_align, vectorcraft_doc::PathAlign::Descender, "above by default"); + let (path, start, end) = on_path(&t); + assert!(path.is_closed()); + assert_eq!((start, end), (0.75, None)); + let pb = path.bounds().unwrap(); + assert!(close(pb.width(), w) && close(pb.height(), w), "{pb:?} vs {w}"); + assert!(close(pb.center().x, b.center().x) && close(pb.center().y, b.center().y), "{pb:?} vs {b:?}"); + // The start bracket, 3/4 of the way round, is the circle's bottom. + let bottom = path.subpaths[0].anchors[3].p; + assert!(close(bottom.y, pb.y1) && close(bottom.x, pb.center().x), "{bottom:?}"); + // Round a circle as wide as the text, the text grows to fill it and does not overflow. + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + assert!(lay.fit_scale > 2.0 && !lay.overflow, "{} {}", lay.fit_scale, lay.overflow); + assert_eq!(r["fitScale"], json!(lay.fit_scale)); + assert_eq!(t.cached_bounds, Some(lay.bounds)); + // Undo gives the point type back. + s.execute("edit.undo", &json!({})).unwrap(); + assert!(matches!(text(&s, id).kind, vectorcraft_doc::TextKind::Point) && !text(&s, id).dynamic_text); + + for (preset, up) in [("arch", true), ("bow", false)] { + s.execute("type.dynamicTextPreset", &json!({"preset": preset, "ids": [id.0]})).unwrap(); + let t = text(&s, id); + let (path, start, end) = on_path(&t); + assert!(!path.is_closed() && t.dynamic_text); + assert_eq!((start, end), (0.0, None)); + let a = &path.subpaths[0].anchors; + let (first, middle, last) = (a[0].p, a[1].p, a[a.len() - 1].p); + // Left to right, the chord as wide as the text, the arc rising (or falling) half its radius. + assert!(first.x < last.x && close(last.x - first.x, w) && close(first.y, last.y), "{preset}: {first:?} {last:?}"); + let pb = path.bounds().unwrap(); + assert!(close(pb.height(), w / 3f64.sqrt() / 2.0), "{preset}: {pb:?}"); + assert!(close(pb.center().y, b.center().y), "{preset}: {pb:?} vs {b:?}"); + assert_eq!(middle.y < first.y, up, "{preset}: {middle:?} {first:?}"); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + assert!(lay.fit_scale > 1.0 && !lay.overflow, "{preset}: {}", lay.fit_scale); + s.execute("edit.undo", &json!({})).unwrap(); + } +} + +#[test] +fn preset_position_and_flip_set_align_to_path_and_direction() { + let mut s = session(); + let (id, _) = point(&mut s, "Over the hill"); + for (position, align) in [ + ("above", vectorcraft_doc::PathAlign::Descender), + ("center", vectorcraft_doc::PathAlign::Center), + ("below", vectorcraft_doc::PathAlign::Ascender), + ] { + s.execute("type.dynamicTextPreset", &json!({"preset": "arch", "position": position})).unwrap(); + assert_eq!(text(&s, id).path_align, align, "{position}"); + } + // Flipped, the arch runs right to left and the text stands on its other side. + s.execute("type.dynamicTextPreset", &json!({"preset": "arch", "flip": true})).unwrap(); + let t = text(&s, id); + let (path, start, end) = on_path(&t); + let a = &path.subpaths[0].anchors; + assert!(a[0].p.x > a[a.len() - 1].p.x, "{path:?}"); + assert_eq!((start, end), (0.0, None)); + assert_eq!(t.path_align, vectorcraft_doc::PathAlign::Descender); + assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow); + // A flipped circle runs the other way round from its bottom. + s.execute("type.dynamicTextPreset", &json!({"preset": "circle", "flip": true})).unwrap(); + let (path, start, end) = on_path(&text(&s, id)); + assert_eq!((start, end), (0.25, None)); + let at = path.subpaths[0].anchors[1].p; + assert!(close(at.y, path.bounds().unwrap().y1), "reversed, the bottom is a quarter of the way: {at:?}"); + // The effect and Spacing of Type on a Path Options stay. + s.execute("type.pathOptions", &json!({"effect": "skew", "spacing": 5})).unwrap(); + s.execute("type.dynamicTextPreset", &json!({"preset": "bow"})).unwrap(); + let t = text(&s, id); + assert_eq!((t.path_effect.id(), t.path_spacing), ("skew", 5.0)); +} + +#[test] +fn presets_fit_type_on_a_path_to_its_path_and_record_nothing_when_nothing_changes() { + let mut s = session(); + let id = on_line(&mut s, "Along the line"); + s.execute("type.dynamicTextPreset", &json!({"preset": "bow"})).unwrap(); + let (path, ..) = on_path(&text(&s, id)); + let a = &path.subpaths[0].anchors; + // The bow is as wide as the 400 pt line, centered where it was. + assert!(close(a[0].p.x, 100.0) && close(a[a.len() - 1].p.x, 500.0), "{a:?}"); + assert!(close(path.bounds().unwrap().center().y, 300.0)); + // The same preset again: nothing changes and no undo step is recorded. + let before = undo_len(&s); + let r = s.execute("type.dynamicTextPreset", &json!({"preset": "bow"})).unwrap(); + assert_eq!(r["ids"], json!([id.0])); + assert_eq!(undo_len(&s), before); + // Another position is a change. + s.execute("type.dynamicTextPreset", &json!({"preset": "bow", "position": "below"})).unwrap(); + assert_eq!(undo_len(&s), before + 1); + // The fit follows later edits to the text. + let f = fit(&s, id); + s.execute("text.setText", &json!({"ids": [id.0], "text": "Along the line and on past it"})).unwrap(); + assert!(fit(&s, id) < f * 0.8, "{} vs {f}", fit(&s, id)); + // On a rotated object the arch still bulges up the page, and on a squashed one the circle is + // round on the page. + let (turned, _) = point(&mut s, "Turned"); + s.execute("object.rotate", &json!({"ids": [turned.0], "angle": 45})).unwrap(); + s.execute("type.dynamicTextPreset", &json!({"preset": "arch", "ids": [turned.0]})).unwrap(); + let t = text(&s, turned); + let page = on_path(&t).0.transformed(t.xf); + let a = &page.subpaths[0].anchors; + assert!(close(a[0].p.y, a[2].p.y) && a[1].p.y < a[0].p.y && a[0].p.x < a[2].p.x, "{a:?}"); + // The same preset again leaves the rotated arch as it is. + let before = undo_len(&s); + s.execute("type.dynamicTextPreset", &json!({"preset": "arch", "ids": [turned.0]})).unwrap(); + assert_eq!(undo_len(&s), before); + let (squashed, _) = point(&mut s, "Squashed"); + s.execute("object.scale", &json!({"ids": [squashed.0], "sx": 200, "sy": 50})).unwrap(); + s.execute("type.dynamicTextPreset", &json!({"preset": "circle", "ids": [squashed.0]})).unwrap(); + let t = text(&s, squashed); + let pb = on_path(&t).0.transformed(t.xf).bounds().unwrap(); + assert!(close(pb.width(), pb.height()), "{pb:?}"); + // Empty 12 pt point type gets a circle four ems (48 pt) across. + let (empty, _) = point(&mut s, ""); + s.execute("type.dynamicTextPreset", &json!({"preset": "circle"})).unwrap(); + let (path, ..) = on_path(&text(&s, empty)); + let pb = path.bounds().unwrap(); + assert!(close(pb.width(), 48.0) && close(pb.height(), 48.0), "{pb:?}"); +} + +#[test] +fn presets_refuse_bad_params_area_type_and_threads() { + let mut s = session(); + let (id, _) = point(&mut s, "Point"); + let before = undo_len(&s); + for p in [ + json!({}), + json!({"preset": "square"}), + json!({"preset": 1}), + json!({"preset": "arch", "position": "top"}), + json!({"preset": "arch", "position": true}), + json!({"preset": "arch", "flip": "yes"}), + ] { + let e = err(s.execute("type.dynamicTextPreset", &p)); + let key = ["position", "flip"].into_iter().find(|k| p.get(k).is_some()).unwrap_or("preset"); + assert!(e.contains(key), "{p}: {e}"); + } + assert_eq!(undo_len(&s), before); + assert!(matches!(text(&s, id).kind, vectorcraft_doc::TextKind::Point)); + // Area type alone is refused; beside point type it is left as it is. + let area = + s.execute("text.create", &json!({"x": 10, "y": 300, "text": "Area", "area": {"width": 100, "height": 50}})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [area]})).unwrap(); + assert!(err(s.execute("type.dynamicTextPreset", &json!({"preset": "circle"}))).contains("select point type or type on a path")); + s.execute("select.set", &json!({"ids": [area, id.0]})).unwrap(); + let r = s.execute("type.dynamicTextPreset", &json!({"preset": "circle"})).unwrap(); + assert_eq!(r["ids"], json!([id.0])); + assert!(matches!(text(&s, NodeId(area)).kind, vectorcraft_doc::TextKind::Area { .. }) && !text(&s, NodeId(area)).dynamic_text); + // Type on a path in a thread is refused by id. + let threaded = on_line(&mut s, "Threaded story"); + let frame = s.execute("shape.rectangle", &json!({"x": 100, "y": 400, "width": 200, "height": 100})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [threaded.0, frame]})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + let before = undo_len(&s); + let e = err(s.execute("type.dynamicTextPreset", &json!({"preset": "arch", "ids": [threaded.0, id.0]}))); + assert!(e.contains("in a thread") && e.contains(&threaded.0.to_string()), "{e}"); + assert_eq!(undo_len(&s), before); +} + /// The id of the first text object in `s`'s document. fn first_text(s: &Session) -> NodeId { let mut found = None; diff --git a/crates/engine/src/tests/flatten.rs b/crates/engine/src/tests/flatten.rs index 84e4724fe6..042f741e0e 100644 --- a/crates/engine/src/tests/flatten.rs +++ b/crates/engine/src/tests/flatten.rs @@ -450,6 +450,54 @@ fn objects_without_transparency_stay_unless_outlined() { } } +/// A row of opaque rectangles 1.5 pt apart (closer than twice the antialiasing margin), with a +/// see-through one touching each end: only the two next to a see-through one are flattened, and +/// the document looks the same. +#[test] +fn opaque_art_that_touches_only_opaque_art_stays() { + let _settings = settings_lock(); + let mut s = session(); + let under = rect(&mut s, 0.0, 50.0, 10.0, 10.0, "#2060c0"); + run(&mut s, "transparency.set", json!({"ids": [under], "opacity": 50})); + let row: Vec = (1..15).map(|i| rect(&mut s, 11.5 * f64::from(i), 50.0, 10.0, 10.0, "#e04010")).collect(); + let over = rect(&mut s, 11.5 * 15.0, 50.0, 10.0, 10.0, "#20a040"); + run(&mut s, "transparency.set", json!({"ids": [over], "opacity": 50, "blend": "Multiply"})); + let before = picture(&s); + run(&mut s, "select.all", json!({})); + let (ids, vector, rasterized) = flatten(&mut s, json!({})); + assert_eq!((vector, rasterized), (4, 0), "two groups of two regions each"); + let (ends, kept) = (row[0], row.last().copied().unwrap()); + assert!(!exists(&s, under) && !exists(&s, over) && !exists(&s, ends) && !exists(&s, kept)); + assert!(row[1..row.len() - 1].iter().all(|id| exists(&s, *id) && ids.contains(id)), "the rest of the row stays"); + assert_eq!(ids.len(), row.len() - 2 + 2); + let diff = changed(&before, &picture(&s)); + assert!(diff == 0.0, "{:.2}% of the pixels changed", diff * 100.0); +} + +/// Opaque art between a see-through object's group and the top of that group, painting over a +/// member, joins the group (along a chain too), so it still paints over it; opaque art under the +/// group stays. +#[test] +fn opaque_art_the_group_would_move_under_joins_it() { + let _settings = settings_lock(); + let mut s = session(); + let d = rect(&mut s, 40.0, 80.0, 25.0, 40.0, "#f0d000"); + let a = rect(&mut s, 60.0, 50.0, 45.0, 40.0, "#e01010"); + let b = rect(&mut s, 20.0, 20.0, 45.0, 35.0, "#10a010"); + let c = rect(&mut s, 0.0, 0.0, 25.0, 25.0, "#1010e0"); + let t = rect(&mut s, 100.0, 50.0, 40.0, 40.0, "#000000"); + run(&mut s, "transparency.set", json!({"ids": [t], "opacity": 50})); + let before = picture(&s); + let (ids, ..) = flatten(&mut s, json!({"ids": [d, a, b, c, t]})); + assert!(exists(&s, d) && ids.contains(&d), "the art under the group stays"); + assert!([a, b, c, t].iter().all(|id| !exists(&s, *id)), "the others are flattened together"); + // Where B paints over A, and C over B, they still do. + for p in [Point::new(62.5, 52.5), Point::new(22.5, 22.5), Point::new(62.5, 85.5), Point::new(102.5, 70.5)] { + assert_eq!(pixel(&picture(&s), p), pixel(&before, p), "at {p:?}"); + } + assert!(changed(&before, &picture(&s)) == 0.0); +} + #[test] fn outlined_type_keeps_its_underline() { let _settings = settings_lock(); diff --git a/crates/engine/src/tests/gradient.rs b/crates/engine/src/tests/gradient.rs index a3f7a2ae6e..73e0172da5 100644 --- a/crates/engine/src/tests/gradient.rs +++ b/crates/engine/src/tests/gradient.rs @@ -32,7 +32,7 @@ fn fill_gradient(s: &Session, id: NodeId) -> GradientPaint { #[test] fn new_gradient_defaults_follow_the_cmyk_document_mode() { - for kind in ["linear", "radial", "conic", "freeform"] { + for kind in ["linear", "radial", "conic", "diamond", "freeform"] { let mut s = Session::new(); s.execute("file.new", &json!({"width":200,"height":200,"colorMode":"cmyk"})).unwrap(); let id = rect(&mut s, 10.0, 10.0, 100.0, 100.0); @@ -586,7 +586,7 @@ fn a_conic_gradient_is_set_edited_transformed_and_saved() { assert_eq!(fill_gradient(&s, id).gradient.kind, GradientKind::Conic, "{name}"); } let e = s.execute("paint.editGradient", &json!({"kind": "spiral"})).unwrap_err().to_string(); - assert!(e.contains("(linear, radial, conic, freeform)"), "{e}"); + assert!(e.contains("(linear, radial, conic, diamond, freeform)"), "{e}"); // Placed, an angle turns the vector about the center. s.execute("paint.setGradientGeom", &json!({"start": [200, 150], "end": [260, 150], "aspect": 50})).unwrap(); s.execute("paint.editGradient", &json!({"angle": 90})).unwrap(); @@ -747,3 +747,137 @@ fn making_a_gradient_conic_is_one_undo_step() { s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(fill_gradient(&s, id), before); } + +/// A diamond gradient through `paint.setFill`, the Gradient panel's edits, transforms, the params +/// agents and swatches read, and the native format. +#[test] +fn a_diamond_gradient_is_set_edited_transformed_and_saved() { + let mut s = session(); + let id = rect(&mut s, 100.0, 100.0, 200.0, 100.0); + s.execute("paint.setFill", &json!({"gradient": {"kind": "diamond"}})).unwrap(); + let g = fill_gradient(&s, id); + assert_eq!(g.gradient.kind, GradientKind::Diamond); + // Unplaced, it runs from the box's center out to a square whose edges pass through the box's + // corners. + let geom = g.resolve(node(&s, id).geometric_bounds().unwrap()); + assert_eq!((geom.start, geom.end), (Point::new(200.0, 150.0), Point::new(350.0, 150.0))); + for (p, t) in [ + (Point::new(300.0, 200.0), 1.0), + (Point::new(100.0, 100.0), 1.0), + (Point::new(250.0, 150.0), 1.0 / 3.0), + (Point::new(200.0, 175.0), 1.0 / 6.0), + ] { + assert!((geom.param_at(GradientKind::Diamond, p) - t).abs() < 1e-9, "{p:?}"); + } + s.execute("paint.editGradient", &json!({"kind": "linear"})).unwrap(); + s.execute("paint.editGradient", &json!({"kind": "DIAMOND"})).unwrap(); + assert_eq!(fill_gradient(&s, id).gradient.kind, GradientKind::Diamond); + let e = s.execute("paint.editGradient", &json!({"kind": "spiral"})).unwrap_err().to_string(); + assert!(e.contains("(linear, radial, conic, diamond, freeform)"), "{e}"); + // Placed, an angle turns the vector about the center. + s.execute("paint.setGradientGeom", &json!({"start": [200, 150], "end": [260, 150], "aspect": 50})).unwrap(); + s.execute("paint.editGradient", &json!({"angle": 90})).unwrap(); + let g = fill_geom(&s, id); + assert!(g.start.distance(Point::new(200.0, 150.0)) < 1e-9 && g.end.distance(Point::new(200.0, 90.0)) < 1e-9, "{g:?}"); + // A reflection keeps each point's color. + let p = Point::new(230.0, 170.0); + let before = g.param_at(GradientKind::Diamond, p); + s.execute("object.reflect", &json!({"axis": "vertical"})).unwrap(); + let m = fill_geom(&s, id); + assert!(m.aspect > 0.0, "{m:?}"); + assert!((m.param_at(GradientKind::Diamond, Point::new(170.0, 170.0)) - before).abs() < 1e-9); + // A rotation carries it along. + let before = m.param_at(GradientKind::Diamond, p); + s.execute("object.rotate", &json!({"angle": 30, "origin": [200, 150]})).unwrap(); + let r = vectorcraft_geom::Affine::rotate_about(-30f64.to_radians(), Point::new(200.0, 150.0)); + let after = fill_geom(&s, id).param_at(GradientKind::Diamond, r * p); + assert!((after - before).abs() < 1e-9, "{before} vs {after}"); + // The params give it back, and the native format keeps it. + s.execute("paint.editGradient", &json!({"aspect": 60})).unwrap(); + let want = fill_gradient(&s, id); + let params = vectorcraft_tools::params::gradient_params(&want); + assert_eq!((params["kind"].as_str(), params["aspect"].as_f64()), (Some("diamond"), Some(60.0))); + s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}})).unwrap(); + s.execute("paint.setFill", &json!({ "gradient": params })).unwrap(); + let got = fill_gradient(&s, id); + assert_eq!((&got.gradient, got.geom), (&want.gradient, want.geom)); + let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let Paint::Gradient(g) = back.node(id).unwrap().appearance.fill_paint() else { panic!("the gradient is lost") }; + assert_eq!((g.gradient.kind, g.geom), (GradientKind::Diamond, want.geom)); +} + +/// A diamond refuses a negative aspect and a focal point, from every command that gives one, and +/// the gradient stays as it was; a null focal point is allowed. +#[test] +fn a_diamond_refuses_a_negative_aspect_and_a_focal_point() { + let mut s = session(); + let id = rect(&mut s, 100.0, 100.0, 200.0, 100.0); + s.execute("paint.setFill", &json!({"gradient": {"kind": "diamond", "start": [200, 150], "end": [260, 150], "aspect": 50}})).unwrap(); + let before = fill_gradient(&s, id); + for p in [ + json!({"gradient": {"kind": "diamond", "aspect": -50}}), + json!({"gradient": {"kind": "diamond", "start": [0, 0], "end": [10, 0], "aspect": -50}}), + ] { + assert!(s.execute("paint.setFill", &p).unwrap_err().to_string().contains("aspect"), "{p}"); + } + assert!(s.execute("paint.editGradient", &json!({"aspect": -50})).is_err()); + assert!(s.execute("paint.setGradientGeom", &json!({"aspect": -50})).is_err()); + assert!(s.execute("paint.editGradient", &json!({"kind": "radial", "aspect": -50})).is_err()); + let e = s.execute("paint.setFill", &json!({"gradient": {"kind": "diamond", "start": [200, 150], "end": [260, 150], "focal": [210, 150]}})); + assert!(e.unwrap_err().to_string().contains("focal")); + assert!(s.execute("paint.setGradientGeom", &json!({"focal": [210, 150]})).unwrap_err().to_string().contains("focal")); + assert_eq!(fill_gradient(&s, id), before); + s.execute("paint.setFill", &json!({"gradient": {"kind": "diamond", "start": [200, 150], "end": [260, 150], "focal": null}})).unwrap(); + assert_eq!(fill_gradient(&s, id).geom.unwrap().focal, None); +} + +/// A free distort that is not affine moves a diamond by the warp near its center, as it moves a +/// radial gradient. +#[test] +fn a_warp_moves_a_diamond_by_its_center() { + let warped = |kind: &str| { + let mut s = session(); + let id = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + s.execute("paint.setFill", &json!({"gradient": {"kind": kind, "start": [20, 30], "end": [90, 30], "aspect": 60}})).unwrap(); + s.execute("object.distort", &json!({"corners": [[0, 0], [100, 0], [140, 130], [-20, 100]]})).unwrap(); + fill_geom(&s, id) + }; + let (diamond, radial) = (warped("diamond"), warped("radial")); + assert!(diamond.start.distance(radial.start) < 1e-9, "{diamond:?} vs {radial:?}"); +} + +/// Saved as `.ai`, as PDF with its editing data and as SVG with its editing data, then opened, a +/// turned, squashed diamond comes back as it was. +#[test] +fn a_diamond_survives_saving_as_ai_pdf_and_svg() { + let mut s = session(); + let id = rect(&mut s, 100.0, 100.0, 200.0, 100.0); + s.execute("paint.setFill", &json!({"gradient": {"kind": "diamond", "start": [180, 140], "end": [240, 110], "aspect": 60}})).unwrap(); + let want = fill_gradient(&s, id); + for format in ["ai", "pdf", "svg"] { + let r = s.execute("file.saveCopy", &json!({"format": format, "preserveEditing": true})).unwrap(); + let mut back = session(); + back.execute("document.open", &json!({"name": format!("t.{format}"), "dataBase64": r["dataBase64"]})).unwrap(); + let mut found = None; + back.doc().unwrap().doc.walk(|n| { + if let (None, Paint::Gradient(g)) = (&found, n.appearance.fill_paint()) { + found = Some(*g.clone()); + } + }); + let got = found.unwrap_or_else(|| panic!("{format}: the gradient is lost")); + assert_eq!((got.gradient.kind, got.geom), (GradientKind::Diamond, want.geom), "{format}"); + } +} + +/// Turning a gradient diamond is one undo step, and undoing it gives the gradient back as it was. +#[test] +fn making_a_gradient_diamond_is_one_undo_step() { + let mut s = session(); + let id = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + s.execute("paint.setFill", &json!({"gradient": {"kind": "radial"}})).unwrap(); + let (before, steps) = (fill_gradient(&s, id), s.doc().unwrap().history.undo.len()); + s.execute("paint.editGradient", &json!({"kind": "diamond", "angle": 45})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(fill_gradient(&s, id), before); +} diff --git a/crates/engine/src/tests/newdoc.rs b/crates/engine/src/tests/newdoc.rs index 067631eeca..4bc58e5f6b 100644 --- a/crates/engine/src/tests/newdoc.rs +++ b/crates/engine/src/tests/newdoc.rs @@ -292,6 +292,57 @@ fn a_saved_preset_with_the_resolution_out_of_range_reads_in_range() { assert_eq!(s.doc().unwrap().doc.raster_effects_ppi, 1.0); } +/// Advanced Options › Align New Objects to Pixel Grid starts on in the Mobile and Web presets and +/// off in the others and without a preset; a `file.new` param overrides the preset; the document +/// takes the value; saved presets and Recent keep it, and a saved preset from before it reads off. +#[test] +fn align_new_objects_to_pixel_grid_follows_the_preset() { + let align = |s: &Session| s.doc().unwrap().doc.setup.align_new_objects_to_pixel_grid; + let mut s = Session::new(); + for category in ["Mobile", "Web"] { + assert!(presets(&mut s, category).iter().all(|p| p["alignNewObjectsToPixelGrid"] == true), "{category}"); + } + for category in ["Print", "Film & Video", "Art & Illustration", "Branding", "Social"] { + assert!(presets(&mut s, category).iter().all(|p| p["alignNewObjectsToPixelGrid"] == false), "{category}"); + } + s.execute("file.new", &json!({"preset": "Web 1920×1080"})).unwrap(); + assert!(align(&s)); + assert_eq!(presets(&mut s, "Recent")[0]["alignNewObjectsToPixelGrid"], true); + s.execute("file.new", &json!({"preset": "Phone 390×844"})).unwrap(); + assert!(align(&s)); + s.execute("file.new", &json!({"preset": "Letter"})).unwrap(); + assert!(!align(&s)); + s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap(); + assert!(!align(&s)); + s.execute("file.new", &json!({"preset": "Web 1920×1080", "alignNewObjectsToPixelGrid": false})).unwrap(); + assert!(!align(&s)); + s.execute("file.new", &json!({"preset": "A4", "alignNewObjectsToPixelGrid": true})).unwrap(); + assert!(align(&s)); + assert!(Session::new().execute("file.new", &json!({"alignNewObjectsToPixelGrid": "on"})).is_err()); + // A Recent entry passed back makes the document it was made as. + let recent = presets(&mut s, "Recent")[0].clone(); + assert_eq!((recent["name"].as_str(), recent["alignNewObjectsToPixelGrid"].as_bool()), (Some("A4"), Some(true))); + s.execute("file.new", &recent).unwrap(); + assert!(align(&s)); + // Saved presets keep it. + s.execute("file.newPresets.save", &json!({"name": "Icons", "width": 64, "height": 64, "alignNewObjectsToPixelGrid": true})).unwrap(); + s.execute("file.newPresets.save", &json!({"name": "Banner", "preset": "Web 1920×1080", "alignNewObjectsToPixelGrid": false})).unwrap(); + let saved: Vec = presets(&mut s, "Saved").iter().map(|p| p["alignNewObjectsToPixelGrid"].clone()).collect(); + assert_eq!(saved, [json!(true), json!(false)]); + s.execute("file.new", &json!({"preset": "Icons"})).unwrap(); + assert!(align(&s)); + s.execute("file.new", &json!({"preset": "Banner"})).unwrap(); + assert!(!align(&s)); + let prefs: crate::Prefs = serde_json::from_value(s.prefs.to_json()).unwrap(); + assert_eq!(prefs.new_doc_presets, s.prefs.new_doc_presets); + // A saved preset from before the setting reads off, whatever its name. + let mut old = Session::new(); + old.apply_prefs(serde_json::from_value(json!({"newDocPresets": [{"name": "My Web", "width": 100, "height": 100}]})).unwrap()); + assert_eq!(presets(&mut old, "Saved")[0]["alignNewObjectsToPixelGrid"], false); + old.execute("file.new", &json!({"preset": "My Web"})).unwrap(); + assert!(!align(&old)); +} + #[test] fn rearrange_keeps_its_grid() { let mut s = new_doc(json!({"width": 100, "height": 50, "artboards": 3})); diff --git a/crates/engine/src/tests/objexpand.rs b/crates/engine/src/tests/objexpand.rs index f638355c52..84e5559851 100644 --- a/crates/engine/src/tests/objexpand.rs +++ b/crates/engine/src/tests/objexpand.rs @@ -393,6 +393,77 @@ fn a_mirrored_or_flat_conic_expands_as_the_canvas_draws_it() { } } +/// Three opaque stops running from the center out to the square. +fn diamond_stops() -> Value { + json!({"kind": "diamond", "stops": [{"offset": 0, "color": "#ffff00"}, {"offset": 0.5, "color": "#ff00ff"}, {"offset": 1, "color": "#004080"}]}) +} + +#[test] +fn a_diamond_gradient_becomes_nested_squares_or_a_mesh_that_paint_it() { + // Objects: nested squares about the center, largest first, together painting what the + // canvas painted. + let mut s = session(); + gradient_rect(&mut s, Rect::new(30.0, 40.0, 170.0, 160.0), diamond_stops(), false); + let before = render(&s); + run(&mut s, "object.expand", json!({"steps": 120})); + let g = selected(&s); + let (_, squares) = clip_parts(&g); + assert_eq!(squares.len(), 120); + let corners = |n: &Node| n.path_data().unwrap().subpaths[0].anchors.iter().map(|a| a.p).collect::>(); + let (outer, inner) = (corners(squares[0]), corners(squares[119])); + assert_eq!((outer.len(), inner.len()), (4, 4)); + let reach = |pts: &[vectorcraft_geom::Point]| pts.iter().map(|p| p.distance(vectorcraft_geom::Point::new(100.0, 100.0))).fold(0.0, f64::max); + assert!(reach(&outer) > reach(&inner) * 100.0, "largest first: {outer:?} then {inner:?}"); + let after = render(&s); + assert!(differing(&before, &after, 48) < 200, "{}", differing(&before, &after, 48)); + assert!(mean_diff(&before, &after) < 2.0, "{}", mean_diff(&before, &after)); + // Translucent stops: each square is a ring with the next one cut out, so none doubles up. + let mut s = session(); + let gr = json!({"kind": "diamond", "stops": [{"offset": 0, "color": "#ff0000", "opacity": 0.5}, {"offset": 1, "color": "#0000ff"}]}); + gradient_rect(&mut s, Rect::new(30.0, 40.0, 170.0, 160.0), gr, false); + let before = render(&s); + run(&mut s, "object.expand", json!({"steps": 60})); + let g = selected(&s); + let (_, squares) = clip_parts(&g); + assert!(squares[..59].iter().all(|n| n.path_data().unwrap().subpaths.len() == 2), "rings"); + assert!(mean_diff(&before, &render(&s)) < 4.0, "{}", mean_diff(&before, &render(&s))); + // Mesh: rings of patches about the center. + let mut s = session(); + gradient_rect(&mut s, Rect::new(30.0, 40.0, 170.0, 160.0), diamond_stops(), false); + let before = render(&s); + run(&mut s, "object.expand", json!({"gradient": "mesh"})); + let g = selected(&s); + let (_, content) = clip_parts(&g); + let NodeKind::Mesh(m) = &content[0].kind else { panic!("a mesh node: {:?}", content[0].kind) }; + assert!(m.is_valid()); + let after = render(&s); + assert!(differing(&before, &after, 48) < 200, "{}", differing(&before, &after, 48)); + assert!(mean_diff(&before, &after) < 2.5, "{}", mean_diff(&before, &after)); +} + +/// A turned, squashed diamond, and one whose square is flat (as a file can hold it), expand as +/// the canvas draws them. +#[test] +fn a_turned_or_flat_diamond_expands_as_the_canvas_draws_it() { + for aspect in [0.6, 0.0] { + for how in [json!({"steps": 120}), json!({"gradient": "mesh"})] { + let mut s = session(); + let id = gradient_rect(&mut s, Rect::new(30.0, 40.0, 170.0, 160.0), diamond_stops(), false); + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let n = d.node_mut(id).unwrap(); + let Paint::Gradient(mut g) = n.appearance.fill_paint().clone() else { panic!("a gradient fill") }; + let (start, end) = (vectorcraft_geom::Point::new(90.0, 110.0), vectorcraft_geom::Point::new(130.0, 80.0)); + g.geom = Some(vectorcraft_color::GradientGeom { start, end, aspect, focal: None }); + n.appearance.set_fill(Paint::Gradient(g)); + let before = render(&s); + run(&mut s, "object.expand", how.clone()); + let after = render(&s); + assert!(differing(&before, &after, 48) < 200, "{aspect} {how}: {}", differing(&before, &after, 48)); + assert!(mean_diff(&before, &after) < 2.5, "{aspect} {how}: {}", mean_diff(&before, &after)); + } + } +} + #[test] fn the_mesh_option_makes_a_mesh_that_paints_the_gradient() { for gradient in [ diff --git a/crates/engine/src/tests/pixelalign.rs b/crates/engine/src/tests/pixelalign.rs index 081497d2ac..adb8bae15d 100644 --- a/crates/engine/src/tests/pixelalign.rs +++ b/crates/engine/src/tests/pixelalign.rs @@ -767,6 +767,131 @@ fn objects_on_path_members_stay_on_the_grid() { assert!(points(&s, a).iter().all(|(_, y)| *y > 105.0), "followed the path: {:?}", points(&s, a)); } +fn align_new(s: &Session) -> bool { + s.doc().unwrap().doc.setup.align_new_objects_to_pixel_grid +} + +/// Align New Objects to Pixel Grid (`document.setup`): while it is on, a rectangle and an open path +/// drawn in the document are pixel-aligned and snapped in the step that makes them, and type is +/// not; art already in the document, duplicates and pastes keep their own setting; turning it off +/// leaves the next drawing as it is drawn, and the object's own setting can be turned off while +/// the document's stays on. +#[test] +fn align_new_objects_makes_new_paths_pixel_aligned() { + let mut s = new_doc(); + assert!(!align_new(&s)); + assert_eq!(s.execute("document.setup", &json!({})).unwrap()["alignNewObjectsToPixelGrid"], false); + let old = path(&mut s, "M10.3 120.6 L40.7 120.6 L40.7 130.3 L10.3 130.3 Z", 2.0); + let old_points = points(&s, old); + let steps = undo_len(&s); + let r = s.execute("document.setup", &json!({"alignNewObjectsToPixelGrid": true})).unwrap(); + assert_eq!(r["alignNewObjectsToPixelGrid"], true); + assert!(align_new(&s)); + assert_eq!(undo_len(&s), steps + 1, "one undo step"); + // Art already in the document stays as it was. + assert!(!aligned(&s, old)); + assert_eq!(points(&s, old), old_points); + // A rectangle is drawn pixel-aligned: with the 2 pt stroke `path` left as the current one, its + // edges go to whole points. + let steps = undo_len(&s); + let rect = s.execute("shape.rectangle", &json!({"x": 10.3, "y": 20.6, "width": 30.4, "height": 9.7})).unwrap()["id"].as_u64().unwrap(); + assert!(aligned(&s, rect)); + assert_eq!(points(&s, rect), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]); + assert_eq!(undo_len(&s), steps + 1, "made and snapped in one undo step"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(NodeId(rect)).is_none()); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(aligned(&s, rect)); + // An open path from the Pen. + let line = s.execute("path.create", &json!({"anchors": [{"x": 50.3, "y": 60.2}, {"x": 90.6, "y": 60.2}]})).unwrap()["id"].as_u64().unwrap(); + assert!(aligned(&s, line)); + // Its horizontal segment goes to a whole point. + assert!(points(&s, line).iter().all(|(_, y)| *y == 60.0), "{:?}", points(&s, line)); + // Type cannot be pixel-aligned. + let text = s.execute("text.create", &json!({"x": 10.3, "y": 200.6, "text": "Hi"})).unwrap()["id"].as_u64().unwrap(); + assert!(!aligned(&s, text)); + // A duplicate and a paste of art that is not pixel-aligned keep it so. + s.execute("select.set", &json!({"ids": [old]})).unwrap(); + s.execute("edit.duplicate", &json!({"dx": 100.3, "dy": 0.4})).unwrap(); + let copy = s.doc().unwrap().selection.objects[0].0; + assert!(copy != old && !aligned(&s, copy)); + s.execute("select.set", &json!({"ids": [old]})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("edit.paste", &json!({})).unwrap(); + let pasted = s.doc().unwrap().selection.objects[0].0; + assert!(pasted != old && pasted != copy && !aligned(&s, pasted)); + // The object's own setting turns off while the document's stays on. + s.execute("object.setPixelAligned", &json!({"ids": [rect], "on": false})).unwrap(); + assert!(!aligned(&s, rect) && align_new(&s)); + // Off: the next rectangle is drawn as it is given, and the first one stays pixel-aligned. + s.execute("document.setup", &json!({"alignNewObjectsToPixelGrid": false})).unwrap(); + let plain = s.execute("shape.rectangle", &json!({"x": 10.3, "y": 20.6, "width": 30.4, "height": 9.7})).unwrap()["id"].as_u64().unwrap(); + assert!(!aligned(&s, plain)); + let drawn = [(10.3, 20.6), (40.7, 20.6), (40.7, 30.3), (10.3, 30.3)]; + assert!(points(&s, plain).iter().zip(drawn).all(|(a, b)| close(*a, b)), "{:?}", points(&s, plain)); + assert!(aligned(&s, line)); + // A value that is not true or false changes nothing. + let steps = undo_len(&s); + assert!(s.execute("document.setup", &json!({"alignNewObjectsToPixelGrid": "yes"})).is_err()); + assert!(!align_new(&s)); + assert_eq!(undo_len(&s), steps); +} + +/// While Align New Objects to Pixel Grid is on, the paths of a new rectangular grid, polar grid +/// and flare are pixel-aligned (the group is not), and a new 3D Box add-on shape is snapped as +/// Align to Pixel Grid snaps the same box drawn without it. +#[test] +fn align_new_objects_reaches_grids_flares_and_add_on_shapes() { + let mut s = new_doc(); + s.execute("document.setup", &json!({"alignNewObjectsToPixelGrid": true})).unwrap(); + let doc = |s: &Session| s.doc().unwrap().doc.clone(); + let children = |s: &Session, id: u64| -> Vec { doc(s).node(NodeId(id)).unwrap().children().unwrap().iter().map(|c| c.id.0).collect() }; + let grid = s.execute("shape.rectangularGrid", &json!({"x": 10.3, "y": 20.6, "width": 100.4, "height": 50.7})).unwrap()["id"].as_u64().unwrap(); + assert!(!aligned(&s, grid)); + let lines = children(&s, grid); + assert!(!lines.is_empty() && lines.iter().all(|id| aligned(&s, *id))); + // Under the 1 pt stroke, a divider sits on a half point across its length, and the frame + // has every corner on half points. + let half = |v: f64| ((v - 0.5) - (v - 0.5).round()).abs() < 1e-9; + let snapped = + |p: &[(f64, f64)]| halves(p) || (p.iter().all(|q| q.1 == p[0].1) && half(p[0].1)) || (p.iter().all(|q| q.0 == p[0].0) && half(p[0].0)); + assert!(lines.iter().all(|id| snapped(&points(&s, *id))), "{:?}", lines.iter().map(|id| points(&s, *id)).collect::>()); + let polar = s.execute("shape.polarGrid", &json!({"x": 200.3, "y": 20.6, "width": 80.4, "height": 80.7})).unwrap()["id"].as_u64().unwrap(); + assert!(!aligned(&s, polar) && children(&s, polar).iter().all(|id| aligned(&s, *id))); + let flare = s.execute("shape.flare", &json!({"cx": 100.3, "cy": 200.6})).unwrap()["id"].as_u64().unwrap(); + assert!(!aligned(&s, flare) && children(&s, flare).iter().all(|id| aligned(&s, *id))); + // Seen straight on, the box's front face has horizontal and vertical edges to snap. + let params = json!({"x": 20.3, "y": 300.6, "width": 60.4, "height": 40.7, "angleX": 0, "angleY": 0, "angleZ": 0}); + let drawn = s.execute("addon.shapes.box3d", ¶ms).unwrap()["id"].as_u64().unwrap(); + assert!(aligned(&s, drawn)); + s.execute("document.setup", &json!({"alignNewObjectsToPixelGrid": false})).unwrap(); + let later = s.execute("addon.shapes.box3d", ¶ms).unwrap()["id"].as_u64().unwrap(); + assert!(!aligned(&s, later)); + let unaligned = points(&s, later); + s.execute("object.setPixelAligned", &json!({"ids": [later]})).unwrap(); + assert_eq!(points(&s, drawn), points(&s, later)); + assert_ne!(points(&s, drawn), unaligned, "the box has segments to snap"); +} + +/// The document's Align New Objects to Pixel Grid is saved in the native file only when on and +/// read back; a file from before it opens with it off. +#[test] +fn align_new_objects_is_saved_and_read_back() { + let mut s = new_doc(); + let off = serde_json::to_value(&s.doc().unwrap().doc.setup).unwrap(); + assert!(off.get("alignNewObjectsToPixelGrid").is_none(), "{off}"); + let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + assert!(!back.setup.align_new_objects_to_pixel_grid); + s.execute("document.setup", &json!({"alignNewObjectsToPixelGrid": true})).unwrap(); + let on = serde_json::to_value(&s.doc().unwrap().doc.setup).unwrap(); + assert_eq!(on["alignNewObjectsToPixelGrid"], true); + let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + assert!(back.setup.align_new_objects_to_pixel_grid); + let mut older = on; + older.as_object_mut().unwrap().remove("alignNewObjectsToPixelGrid"); + assert!(!serde_json::from_value::(older).unwrap().align_new_objects_to_pixel_grid); +} + fn selected_ids(s: &Session) -> Vec { let mut ids: Vec = s.doc().unwrap().selection.objects.iter().map(|i| i.0).collect(); ids.sort_unstable(); diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index 5076978350..d21754e363 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -1587,7 +1587,7 @@ fn a_module_without_permissions_ends_the_grant_of_its_id() { /// that holds such a module and a grant for its id has that grant forgotten when it is attached. #[test] fn a_module_without_permissions_ends_the_grant_kept_in_the_store() { - use vectorcraft_plugins::permissions::{Grant, Permission, sha256_hex}; + use vectorcraft_plugins::permissions::{Grant, GrantToken, Permission, sha256_hex}; let folder = crate::tests_links::Folder::new("plugin-store-stale-grant"); let id = "test.engine.permissions.storedstale"; let other = "test.engine.permissions.storedother"; @@ -1634,7 +1634,11 @@ fn a_module_without_permissions_ends_the_grant_kept_in_the_store() { let future = json!({"id": id, "hash": "00", "permissions": ["doc.read", "ui.panel"]}); std::fs::write(&grants, serde_json::to_vec(&json!([future, other_entry])).unwrap()).unwrap(); vectorcraft_plugins::registry::remove(id); - vectorcraft_plugins::registry::grant(Grant { id: id.into(), hash: sha256_hex(&approved), permissions: vec![Permission::DocRead] }); + vectorcraft_plugins::registry::grant(GrantToken::after_engine_approval(Grant { + id: id.into(), + hash: sha256_hex(&approved), + permissions: vec![Permission::DocRead], + })); let mut fourth = session(); fourth.attach_plugin_store(folder.0.clone()); assert_eq!(ids(), vec![other.to_string()]); @@ -2165,7 +2169,7 @@ fn a_plugin_folder_ends_the_process_grant_of_an_id_it_replaces() { /// With a store attached, a matching grant held only by the process doesn't count. #[test] fn a_stored_grant_covers_only_the_stored_module_it_recorded() { - use vectorcraft_plugins::permissions::{Grant, Permission, sha256_hex}; + use vectorcraft_plugins::permissions::{Grant, GrantToken, Permission, sha256_hex}; let folder = crate::tests_links::Folder::new("plugin-store-recorded-module"); let id = "test.engine.permissions.storedbytes"; let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []})); @@ -2210,7 +2214,11 @@ fn a_stored_grant_covers_only_the_stored_module_it_recorded() { assert!(starts()); let mut attached = session(); attached.attach_plugin_store(folder.0.clone()); - vectorcraft_plugins::registry::grant(Grant { id: id.into(), hash: sha256_hex(&changed), permissions: vec![Permission::DocRead] }); + vectorcraft_plugins::registry::grant(GrantToken::after_engine_approval(Grant { + id: id.into(), + hash: sha256_hex(&changed), + permissions: vec![Permission::DocRead], + })); let inspected = run(&mut attached, "plugin.inspect", json!({"dataBase64": vectorcraft_format::base64_encode(&changed)})); assert_eq!(inspected["needsApproval"], true, "a grant held only by the process doesn't count with a store attached"); run(&mut attached, "plugin.remove", json!({"id": id})); @@ -2668,8 +2676,15 @@ fn a_run_past_the_memory_budget_fails_before_the_plugin_starts() { params.join(",") ); let module = wat::parse_str(tpl::module(&manifest, "unreachable", "", 1)).unwrap(); - // One page of memory: the input and the parameters share 32 KiB. - registry::install(Plugin::load(&module, Limits { max_memory_bytes: 1 << 16, ..Limits::default() }).unwrap()); + // One page of memory: the input and the parameters share 32 KiB. The module lists permissions, + // so its grant is recorded first. + let plugin = Plugin::load(&module, Limits { max_memory_bytes: 1 << 16, ..Limits::default() }).unwrap(); + registry::grant(vectorcraft_plugins::permissions::GrantToken::after_engine_approval(vectorcraft_plugins::permissions::Grant { + id: id.into(), + hash: plugin.hash().to_string(), + permissions: plugin.manifest().permissions(), + })); + registry::install(plugin).unwrap(); let mut s = session(); let small = rect(&mut s, 10.0, "#ff0000"); let long = rect(&mut s, 30.0, "#00ff00"); @@ -2710,7 +2725,7 @@ fn a_run_past_the_memory_budget_fails_before_the_plugin_starts() { assert!(e.contains(says), "{says}: {e}"); assert_eq!((s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()), before, "{says}"); } - assert!(registry::remove(id)); + assert!(registry::remove(id) && registry::revoke(id)); } /// What the user granted is kept in the plug-in store with the module: the next session installs @@ -2801,6 +2816,41 @@ fn granted_permissions_are_kept_and_folders_skip_the_rest() { run(&mut granted, "plugin.remove", json!({"id": id})); } +/// The registry installs a module that lists permissions only under a grant the process holds, so +/// a start and a `plugin.reload` record the store's grant for the module they load. A module in the +/// folder that nobody granted is reported and skipped, and records nothing. +#[test] +fn stored_and_reload_grants_still_install() { + let _folder = PREFS_FOLDER.lock().unwrap_or_else(|e| e.into_inner()); + let store = crate::tests_links::Folder::new("plugin-store-grants-reload"); + let id = "test.engine.permissions.reload"; + let lone = "test.engine.permissions.reload.lone"; + let wasm = asking(id, r#"["doc.read"]"#, &json!({"objects": []})); + let mut s = session(); + s.attach_plugin_store(store.0.clone()); + run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": ["doc.read"]})); + let forget = || { + vectorcraft_plugins::registry::remove(id); + vectorcraft_plugins::registry::revoke(id); + }; + forget(); + let mut next = session(); + next.attach_plugin_store(store.0.clone()); + assert!(vectorcraft_plugins::registry::get(id).is_some(), "installed again from the store"); + assert_eq!(vectorcraft_plugins::registry::granted(id).map(|g| g.hash), Some(vectorcraft_plugins::permissions::sha256_hex(&wasm))); + forget(); + let dir = crate::tests_links::Folder::new("plugin-folder-grants-reload"); + std::fs::write(dir.0.join("granted.wasm"), &wasm).unwrap(); + std::fs::write(dir.0.join("lone.wasm"), asking(lone, r#"["doc.read"]"#, &json!({"objects": []}))).unwrap(); + let r = run(&mut next, "plugin.reload", json!({"path": dir.0.display().to_string()})); + assert_eq!(r["loaded"], json!([id]), "{r}"); + assert!(vectorcraft_plugins::registry::granted(id).is_some(), "the store's grant is the process's too"); + let why = r["failed"][0]["error"].as_str().unwrap(); + assert!(why.contains("needs approval") && r["failed"][0]["file"].as_str().unwrap().ends_with("lone.wasm"), "{r}"); + assert!(vectorcraft_plugins::registry::get(lone).is_none() && vectorcraft_plugins::registry::granted(lone).is_none()); + run(&mut next, "plugin.remove", json!({"id": id})); +} + /// A grant covers the module it was given to: another module under the id, listing the same /// permissions, isn't installed from the store at the next start, a folder load reports it as /// needing approval, and `plugin.install` asks for `grant` again, even with `replace: true`. It diff --git a/crates/engine/src/tests/select_effects.rs b/crates/engine/src/tests/select_effects.rs new file mode 100644 index 0000000000..b30d0ef70e --- /dev/null +++ b/crates/engine/src/tests/select_effects.rs @@ -0,0 +1,236 @@ +//! Clicks, hovers and marquees with the selection tools leave out the area that raster effects +//! (drop shadows, glows, blurs) paint beyond an object: there they reach the object under the +//! effect, or nothing. + +use serde_json::{Value, json}; +use vectorcraft_doc::NodeId; +use vectorcraft_geom::{Point, Rect}; +use vectorcraft_tools::{Cursor, Mods, PointerEvent, PointerKind}; + +use crate::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + s +} + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}")) +} + +fn id_of(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +/// A rectangle filled with `color`. +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64, color: &str) -> NodeId { + let id = id_of(&run(s, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h}))); + run(s, "select.set", json!({"ids": [id.0]})); + run(s, "paint.setFill", json!({"color": color})); + id +} + +/// A drop shadow on the whole of `id`, `dx` to the right, with no blur. +fn shadow(s: &mut Session, id: NodeId, dx: f64) { + run(s, "effect.apply", json!({"effect": "stylize.dropShadow", "ids": [id.0], "params": {"x": dx, "y": 0, "blur": 0}, "item": null})); +} + +fn pixel(s: &Session, x: u32, y: u32) -> [u8; 4] { + let doc = s.doc().unwrap().doc.clone(); + let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + vectorcraft_render::Renderer::new().render(&doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &opts).pixel(x, y) +} + +fn press(s: &mut Session, tool: &str, from: (f64, f64), to: (f64, f64), mods: Mods) -> Vec { + let v = ViewInfo::default(); + s.select_tool(tool, v).unwrap(); + for (k, (x, y)) in [(PointerKind::Down, from), (PointerKind::Drag, to), (PointerKind::Up, to)] { + if k == PointerKind::Drag && from == to { + continue; + } + s.pointer(&PointerEvent::new(k, x, y).with_mods(mods), v).unwrap(); + } + s.active().unwrap().selection.objects.clone() +} + +/// Click with the Selection tool at (x, y) on a 100% view: what is selected afterwards. +fn click(s: &mut Session, x: f64, y: f64) -> Vec { + press(s, "selection", (x, y), (x, y), Mods::default()) +} + +fn bounds(s: &Session, id: NodeId) -> Rect { + s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap() +} + +const WHITE: [u8; 4] = [255; 4]; +const BLUE: [u8; 4] = [0, 0, 255, 255]; + +/// Blue B at x 100 to 160, red A over its left edge's side at x 20 to 80, and A's shadow +/// 40 pt to the right, from x 60 to 120: over the canvas from 80 to 100 and over B from 100 to 120. +fn shadow_over_b(s: &mut Session) -> (NodeId, NodeId) { + let b = rect(s, 100.0, 20.0, 60.0, 60.0, "#0000ff"); + let a = rect(s, 20.0, 20.0, 60.0, 60.0, "#ff0000"); + shadow(s, a, 40.0); + (a, b) +} + +#[test] +fn a_click_on_a_drop_shadow_selects_the_object_under_it() { + let mut s = session(); + let (a, b) = shadow_over_b(&mut s); + // The shadow paints over B and over the canvas. + assert_ne!(pixel(&s, 110, 50), BLUE); + assert_ne!(pixel(&s, 90, 50), WHITE); + run(&mut s, "select.none", json!({})); + assert_eq!(click(&mut s, 110.0, 50.0), [b]); + assert_eq!(click(&mut s, 50.0, 50.0), [a]); +} + +#[test] +fn a_click_on_a_drop_shadow_over_the_canvas_selects_nothing_and_clears_the_selection() { + let mut s = session(); + let (a, _) = shadow_over_b(&mut s); + run(&mut s, "select.set", json!({"ids": [a.0]})); + assert!(click(&mut s, 90.0, 50.0).is_empty()); +} + +#[test] +fn a_click_on_an_outer_glow_or_a_gaussian_blur_outside_the_path_selects_what_is_under_it() { + let mut s = session(); + let a = rect(&mut s, 20.0, 20.0, 60.0, 60.0, "#ff0000"); + run(&mut s, "effect.apply", json!({"effect": "stylize.outerGlow", "ids": [a.0], "params": {"blur": 20, "mode": "normal"}, "item": null})); + assert_ne!(pixel(&s, 88, 50), WHITE); + run(&mut s, "select.none", json!({})); + assert!(click(&mut s, 88.0, 50.0).is_empty()); + // A red square whose blur reaches over the canvas and over the left edge of a blue one. + let mut s = session(); + let b = rect(&mut s, 92.0, 20.0, 60.0, 60.0, "#0000ff"); + let a = rect(&mut s, 20.0, 20.0, 60.0, 60.0, "#ff0000"); + run(&mut s, "effect.apply", json!({"effect": "blur.gaussian", "ids": [a.0], "params": {"radius": 20}, "item": null})); + assert_ne!(pixel(&s, 98, 50), BLUE); + assert_ne!(pixel(&s, 87, 50), WHITE); + run(&mut s, "select.none", json!({})); + assert_eq!(click(&mut s, 98.0, 50.0), [b]); + // (With B deselected, so that the press does not take its bounding box handle.) + run(&mut s, "select.none", json!({})); + assert!(click(&mut s, 87.0, 50.0).is_empty()); +} + +/// The first point of `area` (x0, y0, x1, y1) that the canvas paints, scanning rows. +fn painted_in(s: &Session, (x0, y0, x1, y1): (u32, u32, u32, u32)) -> Option<(f64, f64)> { + let doc = s.doc().unwrap().doc.clone(); + let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let img = vectorcraft_render::Renderer::new().render(&doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &opts); + (y0..y1) + .flat_map(|y| (x0..x1).map(move |x| (x, y))) + .find(|&(x, y)| img.pixel(x, y) != WHITE) + .map(|(x, y)| (f64::from(x) + 0.5, f64::from(y) + 0.5)) +} + +#[test] +fn a_click_on_the_drop_shadow_of_a_group_or_of_point_type_selects_neither() { + let mut s = session(); + let a = rect(&mut s, 20.0, 20.0, 30.0, 30.0, "#ff0000"); + let b = rect(&mut s, 60.0, 20.0, 30.0, 30.0, "#ff0000"); + run(&mut s, "select.set", json!({"ids": [a.0, b.0]})); + let g = id_of(&run(&mut s, "object.group", json!({}))); + shadow(&mut s, g, 80.0); + // The group's shadow lies from x 100 to 170. + assert_ne!(pixel(&s, 110, 35), WHITE); + run(&mut s, "select.none", json!({})); + assert!(click(&mut s, 110.0, 35.0).is_empty()); + assert_eq!(click(&mut s, 35.0, 35.0), [g]); + // Type 40 pt high at y 150 with its shadow 90 pt to the right. + let t = id_of(&run(&mut s, "text.create", json!({"x": 20, "y": 150, "text": "HH", "size": 40}))); + shadow(&mut s, t, 90.0); + let right = bounds(&s, t).x1; + assert!(right < 100.0, "{right}"); + let at = painted_in(&s, (right.ceil() as u32 + 10, 110, 199, 160)).expect("the type's shadow paints"); + run(&mut s, "select.none", json!({})); + assert!(click(&mut s, at.0, at.1).is_empty(), "{at:?}"); +} + +#[test] +fn a_click_on_the_drop_shadow_of_a_symbols_art_does_not_select_the_instance() { + let mut s = session(); + let a = rect(&mut s, 20.0, 20.0, 60.0, 60.0, "#ff0000"); + shadow(&mut s, a, 40.0); + run(&mut s, "select.set", json!({"ids": [a.0]})); + let inst = id_of(&run(&mut s, "symbol.new", json!({"name": "Shadowed"}))); + assert_ne!(pixel(&s, 100, 50), WHITE); + run(&mut s, "select.none", json!({})); + assert!(click(&mut s, 100.0, 50.0).is_empty()); + assert_eq!(click(&mut s, 50.0, 50.0), [inst]); +} + +#[test] +fn a_click_on_the_drop_shadow_of_an_image_outside_its_frame_does_not_select_it() { + let mut s = session(); + let mut png = vec![]; + image::RgbaImage::from_pixel(30, 30, image::Rgba([255, 0, 0, 255])) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + let r = run(&mut s, "file.place", json!({"name": "red.png", "dataBase64": vectorcraft_format::base64_encode(&png), "at": [65, 65]})); + let im = NodeId(r["ids"][0].as_u64().unwrap()); + // The frame lies from x 50 to 80 and the shadow from 90 to 120. + shadow(&mut s, im, 40.0); + assert_ne!(pixel(&s, 105, 65), WHITE); + run(&mut s, "select.none", json!({})); + assert!(click(&mut s, 105.0, 65.0).is_empty()); + assert_eq!(click(&mut s, 65.0, 65.0), [im]); +} + +#[test] +fn shift_click_direct_selection_and_hover_on_a_drop_shadow_pass_through_it() { + let mut s = session(); + let (a, b) = shadow_over_b(&mut s); + // Shift-click on the shadow over B adds B and keeps A. + run(&mut s, "select.set", json!({"ids": [a.0]})); + let shift = Mods { shift: true, ..Mods::default() }; + let mut sel = press(&mut s, "selection", (110.0, 50.0), (110.0, 50.0), shift); + sel.sort_by_key(|n| n.0); + let mut both = vec![a, b]; + both.sort_by_key(|n| n.0); + assert_eq!(sel, both); + // Direct Selection takes B through the shadow, and nothing over the canvas. + run(&mut s, "select.none", json!({})); + let sel = press(&mut s, "directSelection", (110.0, 50.0), (110.0, 50.0), Mods::default()); + assert!(sel.contains(&b) && !sel.contains(&a), "{sel:?}"); + run(&mut s, "select.none", json!({})); + assert!(press(&mut s, "directSelection", (90.0, 50.0), (90.0, 50.0), Mods::default()).is_empty()); + // With A selected, the pointer over its shadow is the plain arrow, and over A the move pointer. + run(&mut s, "select.set", json!({"ids": [a.0]})); + let v = ViewInfo::default(); + s.select_tool("selection", v).unwrap(); + assert_eq!(s.cursor(Point::new(50.0, 50.0), Mods::default(), v), Cursor::Move); + assert_eq!(s.cursor(Point::new(110.0, 50.0), Mods::default(), v), Cursor::Arrow); + assert_eq!(s.cursor(Point::new(92.0, 50.0), Mods::default(), v), Cursor::Arrow); +} + +#[test] +fn a_drag_from_the_drop_shadow_of_a_selected_object_does_not_move_it() { + let mut s = session(); + let (a, b) = shadow_over_b(&mut s); + run(&mut s, "select.set", json!({"ids": [a.0]})); + let before = bounds(&s, a); + // From the shadow over the canvas: a marquee that touches nothing. + assert!(press(&mut s, "selection", (92.0, 50.0), (96.0, 60.0), Mods::default()).is_empty()); + assert_eq!(bounds(&s, a), before); + // From the shadow over B: B moves, A stays. + run(&mut s, "select.set", json!({"ids": [a.0]})); + let b_before = bounds(&s, b); + assert_eq!(press(&mut s, "selection", (110.0, 50.0), (110.0, 60.0), Mods::default()), [b]); + assert_eq!(bounds(&s, a), before); + assert!((bounds(&s, b).y0 - b_before.y0 - 10.0).abs() < 1e-6, "{:?}", bounds(&s, b)); +} + +#[test] +fn a_marquee_that_touches_only_a_drop_shadow_does_not_select_its_object() { + let mut s = session(); + let (_, b) = shadow_over_b(&mut s); + run(&mut s, "select.none", json!({})); + assert!(press(&mut s, "selection", (85.0, 40.0), (95.0, 60.0), Mods::default()).is_empty()); + // From above B, over B and the shadow: B only. + assert_eq!(press(&mut s, "selection", (105.0, 5.0), (115.0, 30.0), Mods::default()), [b]); +} diff --git a/crates/engine/src/tests/stylelib.rs b/crates/engine/src/tests/stylelib.rs index 5124fb4502..d1d4155904 100644 --- a/crates/engine/src/tests/stylelib.rs +++ b/crates/engine/src/tests/stylelib.rs @@ -44,6 +44,7 @@ fn builtin_libraries_are_listed_with_original_styles_using_catalogued_effects() assert!(libs.len() >= 8 && libs.iter().all(|l| l["category"] == "builtIn"), "{libs:?}"); assert!(libs.iter().any(|l| l["id"] == "game-hud" && l["name"] == "Game HUD"), "{libs:?}"); assert!(libs.iter().any(|l| l["id"] == "status-effects" && l["name"] == "Status Effects"), "{libs:?}"); + assert!(libs.iter().any(|l| l["id"] == "textures" && l["name"] == "Textures" && l["count"] == 8), "{libs:?}"); for l in libs { let id = l["id"].as_str().unwrap(); let g = run(&mut s, "graphicStyle.library", json!({"library": id})); @@ -64,6 +65,10 @@ fn builtin_libraries_are_listed_with_original_styles_using_catalogued_effects() let status = run(&mut s, "graphicStyle.library", json!({"library": "status-effects"})); let status_names: Vec<&str> = status["styles"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap()).collect(); assert_eq!(status_names, ["Poison Tint", "Freeze Edge", "Burn Aura", "Stun Pulse", "Stealth Fade", "Shield Buff"]); + let textures = run(&mut s, "graphicStyle.library", json!({"library": "textures"})); + let texture_names: Vec<&str> = textures["styles"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap()).collect(); + let expected = ["Fine Grain", "Canvas Weave", "Burlap Sack", "Cracked Glaze", "Mosaic Floor", "Quilted Squares", "Leaded Glass", "Damp Paper"]; + assert_eq!(texture_names, expected); let id = rect(&mut s, 10.0, 20.0, 40.0, 30.0); let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "game-hud", "name": "Mana Glow", "apply": true, "ids": [id.0]})); assert_eq!((names(&r["added"]), r["applied"].as_str()), (vec!["Mana Glow"], Some("Mana Glow"))); @@ -167,6 +172,53 @@ fn add_with_null_ids_applies_to_selection() { assert_eq!(doc(&s).node(id).unwrap().extras().graphic_style, Some(style.id)); } +#[test] +fn textures_styles_apply_their_texture_effects_and_keep_their_params_through_save_and_open() { + let mut s = session(); + let (_, lib) = cmd::stylelib::library(&s, "textures").unwrap(); + let mut ids = vec![]; + for (i, g) in lib.styles.iter().enumerate() { + let id = rect(&mut s, (i % 4) as f64 * 50.0, (i / 4) as f64 * 50.0, 40.0, 40.0); + let before = undo_len(&s); + let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "textures", "name": g.name, "apply": true, "ids": [id.0]})); + assert_eq!((names(&r["added"]), r["applied"].as_str()), (vec![g.name.as_str()], Some(g.name.as_str()))); + assert_eq!(undo_len(&s), before + 1, "{}: added and applied as one step", g.name); + ids.push(id); + } + let effects = |s: &Session| -> Vec> { + ids.iter().map(|id| doc(s).node(*id).unwrap().appearance.effects.iter().map(|e| (e.id.clone(), e.params.clone())).collect()).collect() + }; + let applied = effects(&s); + let effect_ids: Vec> = applied.iter().map(|fx| fx.iter().map(|(id, _)| id.as_str()).collect()).collect(); + assert_eq!( + effect_ids, + [ + ["texture.grain"], + ["texture.texturizer"], + ["texture.texturizer"], + ["texture.craquelure"], + ["texture.mosaicTiles"], + ["texture.patchwork"], + ["texture.stainedGlass"], + ["sketch.waterPaper"], + ] + ); + assert_eq!(applied[1][0].1["texture"], "canvas"); + assert_eq!(applied[3][0].1["crackSpacing"], 12.0); + for (fx, g) in applied.iter().zip(&lib.styles) { + let want: Vec<(String, Value)> = g.appearance.effects.iter().map(|e| (e.id.clone(), e.params.clone())).collect(); + assert_eq!(fx, &want, "{} applies the library's params", g.name); + } + let b64 = run(&mut s, "document.serialize", json!({"format": "vectorcraft"}))["dataBase64"].as_str().unwrap().to_string(); + run(&mut s, "document.open", json!({"name": "textures.vectorcraft", "dataBase64": b64})); + assert_eq!(effects(&s), applied, "the params survive a save and open"); + for g in &lib.styles { + let saved = doc(&s).graphic_style(&g.name).unwrap(); + assert!(saved.same_look(g), "{} keeps its look", g.name); + assert!(ids.iter().any(|id| doc(&s).node(*id).unwrap().extras().graphic_style == Some(saved.id)), "{} stays linked", g.name); + } +} + /// A document with pattern `name` (a dot) and its swatch. fn add_pattern(s: &mut Session, name: &str, radius: f64) { s.edit("Pattern", |d, _| { diff --git a/crates/engine/tests/plugin_import.rs b/crates/engine/tests/plugin_import.rs index b780c115c3..f091062308 100644 --- a/crates/engine/tests/plugin_import.rs +++ b/crates/engine/tests/plugin_import.rs @@ -326,7 +326,7 @@ fn an_importer_past_its_budget_opens_nothing() { ]; for (id, limits, _) in &cases { let wasm = wat::parse_str(tpl::module(&tpl::importer(id, r#"["tbg"]"#, "{}"), SPINS, "", 1)).unwrap(); - vectorcraft_plugins::registry::install(Plugin::load(&wasm, limits.clone()).unwrap()); + vectorcraft_plugins::registry::install(Plugin::load(&wasm, limits.clone()).unwrap()).unwrap(); } let mut s = Session::new(); for (id, _, why) in &cases { diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index a99646c9c2..f2ba516b46 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -7575,3 +7575,45 @@ fn conic_gradients_are_written_as_linear_gradients_with_their_stops() { let Some(Paint::Gradient(swatch)) = back.swatch("Sweep").map(|s| &s.paint) else { panic!("the swatch is lost") }; assert_eq!(swatch.gradient.kind, GradientKind::Linear); } + +/// The data has no diamond gradient: a diamond fill is the radial gradient through its square's +/// corners (centered on it, its vector and the aspect across it), with the same stops, and so is +/// a diamond gradient swatch, with a warning. +#[test] +fn diamond_gradients_are_written_as_radial_gradients_through_their_corners() { + let stops = + vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(0.6, SKY), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let mut g = GradientPaint::new(Gradient::new(GradientKind::Diamond, stops.clone())); + let (start, end) = (vectorcraft_geom::Point::new(100.0, 50.0), vectorcraft_geom::Point::new(150.0, 50.0)); + // A focal point (a plug-in's or a file's) draws nothing on a diamond and isn't written. + let focal = Some(vectorcraft_geom::Point::new(110.0, 55.0)); + g.geom = Some(vectorcraft_color::GradientGeom { start, end, aspect: 0.5, focal }); + let mut doc = Document::new(200.0, 100.0); + let id = doc.alloc_id(); + let l = doc.layers[0].id; + let fill = Paint::Gradient(Box::new(g.clone())); + doc.insert(Some(l), 0, Node::path(id, shapes::rectangle(Rect::new(20.0, 10.0, 180.0, 90.0)), Appearance::basic(fill, Paint::None, 0.0))).unwrap(); + let mut swatches: Vec = doc.swatches.iter().cloned().collect(); + swatches.push(Swatch { name: "Gem".into(), paint: Paint::Gradient(Box::new(GradientPaint { geom: None, ..g })), global: false, spot: false }); + doc.swatches = swatches.into(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w.contains("diamond gradients are written as radial gradients")), "{:?}", out.warnings); + let back = read_back(&doc); + let mut read = None; + back.walk(|n| { + if let Paint::Gradient(g) = n.appearance.fill_paint() { + read = Some(g.clone()); + } + }); + let read = read.expect("a gradient fill"); + assert_eq!(read.gradient.kind, GradientKind::Radial); + assert_eq!( + read.gradient.stops.iter().map(|s| (s.offset, s.color.to_hex())).collect::>(), + stops.iter().map(|s| (s.offset, s.color.to_hex())).collect::>() + ); + let geom = read.geom.unwrap(); + assert!(geom.start.distance(start) < 0.01 && geom.end.distance(end) < 0.01 && (geom.aspect - 0.5).abs() < 0.01, "{geom:?}"); + assert!(geom.focal.is_none_or(|f| f.distance(start) < 0.01), "{geom:?}"); + let Some(Paint::Gradient(swatch)) = back.swatch("Gem").map(|s| &s.paint) else { panic!("the swatch is lost") }; + assert_eq!(swatch.gradient.kind, GradientKind::Radial); +} diff --git a/crates/eps/src/ai_editing/writer/paints.rs b/crates/eps/src/ai_editing/writer/paints.rs index 892a0d1b65..0dbe24c937 100644 --- a/crates/eps/src/ai_editing/writer/paints.rs +++ b/crates/eps/src/ai_editing/writer/paints.rs @@ -45,12 +45,22 @@ pub(super) struct PatternOut { used: bool, } -/// Can `g` be written as a gradient (a linear, radial or conic one with stops, no more than the -/// `.ai` reader reads; a conic is written linear, [`CONIC`])? +pub(in crate::ai_editing) const DIAMOND: &str = + "diamond gradients are written as radial gradients through their corners, with the same stops, in the editing data for other apps"; + +/// Can `g` be written as a gradient (a linear, radial, conic or diamond one with stops, no more +/// than the `.ai` reader reads; a conic is written linear, [`CONIC`], and a diamond radial, +/// [`DIAMOND`])? fn writable_gradient(g: &Gradient) -> bool { g.kind != GradientKind::Freeform && !g.stops.is_empty() && g.stops.len() <= crate::import::MAX_GRADIENT_STOPS } +/// Is a gradient of `kind` written as a radial one: a radial gradient, or a diamond (the ellipse +/// through its square's corners, [`DIAMOND`])? +fn written_radial(kind: GradientKind) -> bool { + matches!(kind, GradientKind::Radial | GradientKind::Diamond) +} + /// The first name of `Gradient`, `Gradient 2`, `Gradient 3`, … numbered `next` or higher for /// which `taken` returns false (`Gradient` is number 1); `next` is set past it. pub(in crate::ai_editing) fn free_gradient_name(next: &mut u64, mut taken: impl FnMut(&str) -> bool) -> String { @@ -128,7 +138,7 @@ impl Writer<'_> { let Paint::Gradient(g) = p else { return p.clone() }; let geom = g.resolve(bounds); let finite = [geom.start.x, geom.start.y, geom.end.x, geom.end.y, geom.aspect].iter().all(|v| v.is_finite()); - let radial = g.gradient.kind == GradientKind::Radial; + let radial = written_radial(g.gradient.kind); if g.gradient.kind == GradientKind::Freeform { self.warn(GRADIENTS); return Paint::solid(average(&g.gradient)); @@ -244,7 +254,7 @@ impl Writer<'_> { self.instance_head(g, clip, path); let m = self.grad_space; let u = geom.end - geom.start; - let radial = g.gradient.kind == GradientKind::Radial; + let radial = written_radial(g.gradient.kind); // The unit vector along the gradient, and across it as long. let frame = |from: f64, to: f64| { let p = geom.start + u * from; @@ -275,10 +285,15 @@ impl Writer<'_> { /// A gradient instance's focal point (`Bh`, radial gradients only) and its `Bg` line, as /// [`Self::instance`] writes them; also what a fill's art style holds after its `Bb`. pub(super) fn instance_head(&mut self, g: &GradientPaint, clip: bool, path: &BezPath) { - let Some(geom) = g.geom else { return }; + let Some(mut geom) = g.geom else { return }; + if g.gradient.kind != GradientKind::Radial { + // Only a radial gradient has a focal point (a plug-in's or a file's other kinds can + // carry one that draws nothing): a diamond's circle stays centered. + geom.focal = None; + } let name = self.define_gradient(&g.gradient, g.swatch.as_deref()); let m = self.grad_space; - let radial = g.gradient.kind == GradientKind::Radial; + let radial = written_radial(g.gradient.kind); let place = placement(&geom, radial, &(self.canvas * path.clone()), self.canvas); if radial { let c = m * geom.start; @@ -305,8 +320,11 @@ impl Writer<'_> { if d.gradient.kind == GradientKind::Conic { self.warn(CONIC); } + if d.gradient.kind == GradientKind::Diamond { + self.warn(DIAMOND); + } let name = ps::bytes(d.name.as_bytes()); - let radial = d.gradient.kind == GradientKind::Radial; + let radial = written_radial(d.gradient.kind); let _ = writeln!(s, "%AI5_BeginGradient: {name}\n{name} {} {} Bd\n[", u8::from(radial), d.gradient.stops.len()); let mut stops = sorted_stops(&d.gradient); if !radial { @@ -508,7 +526,7 @@ impl Writer<'_> { let swatch = Some(sw.name.as_str()); let name = self.define_gradient(&g.gradient, swatch); s.push_str("Bb\n"); - if g.gradient.kind == GradientKind::Radial { + if written_radial(g.gradient.kind) { s.push_str("0 0 0 0 Bh\n"); } let _ = writeln!(s, "2 {} 0 0 0 1 1 0 0 1 0 0 1 Bg\n0 BB", ps::bytes(name.as_bytes())); diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs index 68c7610308..e256e305c2 100644 --- a/crates/eps/src/legacy_ai.rs +++ b/crates/eps/src/legacy_ai.rs @@ -108,6 +108,7 @@ pub const NAMES: &str = "object names are left out (legacy .ai files name only l const PATTERNS: &str = "pattern fills are written as their tiles, clipped to the shape"; const FREEFORM: &str = "freeform gradients are written as their average colour"; const CONIC: &str = "conic gradients are written as solid wedges clipped to the shape (legacy .ai files have no conic gradient)"; +const DIAMOND: &str = "diamond gradients are written as four linear gradients clipped to the shape (legacy .ai files have no diamond gradient)"; /// Wedges a conic gradient is written as: one a degree. const CONIC_WEDGES: usize = 360; const STROKES: &str = diff --git a/crates/eps/src/legacy_ai/encoding.rs b/crates/eps/src/legacy_ai/encoding.rs index 60eb9b0c1c..5528ad7529 100644 --- a/crates/eps/src/legacy_ai/encoding.rs +++ b/crates/eps/src/legacy_ai/encoding.rs @@ -201,7 +201,9 @@ pub(super) fn segment_point(path: &BezPath, fraction: f64) -> f64 { pub(super) fn direct_fill(p: &Paint) -> bool { match p { Paint::Solid { .. } => true, - Paint::Gradient(g) => !matches!(g.gradient.kind, GradientKind::Freeform | GradientKind::Conic) && !g.gradient.stops.is_empty(), + Paint::Gradient(g) => { + !matches!(g.gradient.kind, GradientKind::Freeform | GradientKind::Conic | GradientKind::Diamond) && !g.gradient.stops.is_empty() + } _ => false, } } diff --git a/crates/eps/src/legacy_ai/tests.rs b/crates/eps/src/legacy_ai/tests.rs index 8917c9aa73..43b28c934a 100644 --- a/crates/eps/src/legacy_ai/tests.rs +++ b/crates/eps/src/legacy_ai/tests.rs @@ -110,6 +110,35 @@ fn conic_gradients_are_written_as_wedges_clipped_to_the_shape() { } } +/// The format has no diamond gradient: a diamond fill, and a diamond stroke (written as its filled +/// outline), are four path objects clipped to the shape, each a linear gradient over a quadrant +/// of the diamond's frame, with a warning; the definition is not named after the swatch. +#[test] +fn diamond_gradients_are_written_as_four_linear_gradients_clipped_to_the_shape() { + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let mut g = GradientPaint::new(Gradient::new(GradientKind::Diamond, stops)); + g.geom = Some(vectorcraft_color::GradientGeom { + start: vectorcraft_geom::Point::new(100.0, 50.0), + end: vectorcraft_geom::Point::new(140.0, 30.0), + aspect: 0.7, + focal: None, + }); + g.swatch = Some("Gem".into()); + let diamond = Paint::Gradient(Box::new(g)); + for (fill, stroke) in [(diamond.clone(), Paint::None), (Paint::None, diamond)] { + let mut doc = Document::new(200.0, 100.0); + let n = shape(&mut doc, shapes::ellipse(Rect::new(20.0, 10.0, 180.0, 90.0)), fill.clone(), stroke.clone()); + let l = doc.layers[0].id; + doc.insert(Some(l), 0, n).unwrap(); + let out = write(&doc); + assert!(out.warnings.iter().any(|w| w == DIAMOND), "{:?}", out.warnings); + let text = String::from_utf8_lossy(&out.bytes); + assert_eq!(text.matches("%AI5_BeginGradient: (Gradient)").count(), 1, "one linear definition: {text}"); + assert!(!text.contains("(Gem)"), "not named after the swatch"); + assert_eq!(text.matches(" Bg\n").count(), 4, "a gradient a quadrant"); + } +} + /// `doc` written with options `o`. Every file the tests write is also run as a PostScript program /// ([`prints_as`]) and must draw what `doc` draws. fn write_with(doc: &Document, o: LegacyAiOptions) -> LegacyAiOutput { diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs index 068f534868..8f8fc4ccf2 100644 --- a/crates/eps/src/legacy_ai/writer.rs +++ b/crates/eps/src/legacy_ai/writer.rs @@ -9,7 +9,7 @@ use super::{ ROTATED_TYPE, Rect, STROKES, SUBLAYERS, Shape, StrokeLayer, SubPath, TITLE_AND_COLOR_NAMES, TRANSPARENCY, TYPE, TYPE_ATTRIBUTES, TextKind, TextObject, UNDECODED, Vec2, Write, ai_ops, latin, ps, stroke, }; -use super::{CONIC, CONIC_WEDGES}; +use super::{CONIC, CONIC_WEDGES, DIAMOND}; pub(super) struct Writer<'a> { doc: &'a Document, @@ -429,6 +429,11 @@ impl<'a> Writer<'a> { { return self.conic_object(path, rule, f, g); } + if let Paint::Gradient(g) = &f.paint + && g.gradient.kind == GradientKind::Diamond + { + return self.diamond_object(path, rule, f, g); + } let Paint::Pattern { pattern, xf } = &f.paint else { // A freeform gradient: its average colour. self.warn(FREEFORM); @@ -497,6 +502,41 @@ impl<'a> Writer<'a> { self.line("Q"); } + /// A diamond gradient fill (the format has no diamond gradient) as four linear gradients + /// clipped to `path` (filled by `rule`), one for each quadrant of its frame and equal to the + /// diamond there, each running on under the next + /// ([`vectorcraft_color::GradientGeom::diamond_quadrants`]). A diamond without an extent is its + /// last stop. + fn diamond_object(&mut self, path: &PathData, rule: FillRule, f: &FillLayer, g: &GradientPaint) { + let area = path.to_bezpath().bounding_box(); + let Some(quadrants) = g.resolve(area).diamond_quadrants(area) else { + let color = g.gradient.stops.last().map_or(Color::gray(0.5), |s| s.color); + let flat = FillLayer { paint: Paint::solid(color), ..f.clone() }; + return self.path_object(path, rule, Some(&flat), None); + }; + self.warn(DIAMOND); + self.line("q"); + self.fill_rule(rule); + let several = path.subpaths.len() > 1; + if several { + self.line("*u"); + } + for sp in path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()) { + self.mask(sp); + } + if several { + self.line("*U"); + } + let gradient = Gradient { kind: GradientKind::Linear, ..g.gradient.clone() }; + for (pts, geom) in quadrants { + // Not named after the swatch: the definition is a linear gradient, not the swatch's. + let linear = GradientPaint { gradient: gradient.clone(), geom: Some(geom), swatch: None, ..g.clone() }; + let quadrant = FillLayer { paint: Paint::Gradient(Box::new(linear)), ..f.clone() }; + self.path_object(&PathData::single(SubPath::polyline(&pts, true)), FillRule::NonZero, Some(&quadrant), None); + } + self.line("Q"); + } + /// A path object painted with `fill` (a colour or a linear or radial gradient) and `stroke` /// (a plain line of a colour) over it; several subpaths make a compound path. fn path_object(&mut self, path: &PathData, rule: FillRule, fill: Option<&FillLayer>, stroke: Option<&StrokeLayer>) { diff --git a/crates/eps/src/scene.rs b/crates/eps/src/scene.rs index 3b2a91f0ff..96205aa7d3 100644 --- a/crates/eps/src/scene.rs +++ b/crates/eps/src/scene.rs @@ -43,6 +43,7 @@ const SPOT_GRADIENTS_L2: &str = const SPOT_GRADIENTS_SHARED: &str = "gradients whose spot inks can't share a DeviceN color space are written without the spot inks"; const SPOT_GRADIENTS_PERCEPTUAL: &str = "Perceptual gradients of spot colors are written in process colors"; const CONIC_WEDGES: &str = "conic gradients are written as solid wedges (EPS has no conic shading)"; +const DIAMOND_QUADRANTS: &str = "diamond gradients are written as four linear gradients (EPS has no diamond shading)"; /// Wedges a conic gradient is written as: one a degree. const CONIC_WEDGE_COUNT: usize = 360; /// The most colorants a DeviceN color space may have (PostScript's limit). @@ -934,9 +935,9 @@ impl<'a> Scene<'a> { (space, out) } - /// Paint all of `area` (inside the current clip) with linear, radial or conic gradient `g`, - /// placed on `bounds`: a smooth shading at Level 3, else stepped fills (a conic: wedges at - /// both levels). + /// Paint all of `area` (inside the current clip) with linear, radial, conic or diamond gradient + /// `g`, placed on `bounds`: a smooth shading at Level 3, else stepped fills (a conic: wedges at + /// both levels; a diamond: four linear gradients, [`Self::diamond_quadrants`]). fn gradient(&mut self, g: &GradientPaint, bounds: Rect, area: Rect) { let geom = g.resolve(bounds); let (space, stops) = self.gradient_stops(&g.gradient); @@ -951,6 +952,7 @@ impl<'a> Scene<'a> { let smooth = self.o.level == Level::Three && !self.o.compatible_gradients; match g.gradient.kind { GradientKind::Conic => self.conic_steps(&geom, area, space, &stops), + GradientKind::Diamond => self.diamond_quadrants(&geom, area, space, &stops, smooth), GradientKind::Radial => { let r = geom.length().max(1e-6); let squash = geom.radial_squash(); @@ -999,6 +1001,43 @@ impl<'a> Scene<'a> { } } + /// A diamond gradient over `area` as four linear gradients, each clipped to a quadrant of its + /// frame and equal to the diamond there (PostScript has no diamond shading; + /// [`vectorcraft_color::GradientGeom::diamond_quadrants`]): smooth axial shadings, else + /// stepped fills. Each quadrant but the last runs on under the next (stops are opaque here), + /// so no hairline shows between them. A diamond without an extent is its last stop. + fn diamond_quadrants( + &mut self, + geom: &vectorcraft_color::GradientGeom, + area: Rect, + space: &GradientSpace, + stops: &[(f64, Vec)], + smooth: bool, + ) { + let Some(quadrants) = geom.diamond_quadrants(area) else { + if let Some((_, c)) = stops.last() { + self.set_comps(space, c); + self.rect_fill(area); + } + return; + }; + self.warn(DIAMOND_QUADRANTS); + for (pts, q) in quadrants { + self.line("q"); + for (i, p) in pts.iter().enumerate() { + push_nums(&mut self.body, &[p.x, p.y]); + self.line(if i == 0 { "newpath moveto" } else { "lineto" }); + } + self.line("closepath clip newpath"); + if smooth { + self.shading(2, space, &[q.start.x, q.start.y, q.end.x, q.end.y], stops); + } else { + self.linear_steps(q.start, q.end, area, space, stops); + } + self.line("Q"); + } + } + /// A Level 3 smooth shading of `kind` (2 axial, 3 radial) at `coords`, extended both ways. fn shading(&mut self, kind: u8, space: &GradientSpace, coords: &[f64], stops: &[(f64, Vec)]) { let n = space.n(); diff --git a/crates/eps/src/tests.rs b/crates/eps/src/tests.rs index 96cf75c02a..db9ae2837c 100644 --- a/crates/eps/src/tests.rs +++ b/crates/eps/src/tests.rs @@ -217,6 +217,33 @@ fn conic_gradients_are_solid_wedges_at_both_levels() { } } +/// PostScript has no diamond shading: a diamond is four axial shadings at Level 3 (stepped fills at +/// Level 2), each clipped to a quadrant of its frame, and read back from the PostScript it looks +/// as the canvas paints it, with no hairline between the quadrants. +#[test] +fn diamond_gradients_are_four_linear_gradients_clipped_to_quadrants() { + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let mut g = GradientPaint::new(Gradient::new(GradientKind::Diamond, stops)); + g.geom = Some(vectorcraft_color::GradientGeom { start: Point::new(100.0, 70.0), end: Point::new(140.0, 40.0), aspect: 0.6, focal: None }); + let paint = Paint::Gradient(Box::new(g)); + let d = doc_with(|d| vec![path(d, shapes::rectangle(Rect::new(20.0, 20.0, 180.0, 120.0)), paint, Paint::None, 0.0)]); + for (o, shadings) in [(opts(&d), 4), (EpsOptions { level: Level::Two, ..opts(&d) }, 0)] { + let out = export(&d, &o, None, None).unwrap(); + assert!(out.warnings.iter().any(|w| w.contains("diamond gradients are written as four linear gradients")), "{:?}", out.warnings); + let page = page_of(&d, &o); + assert_eq!(page.matches("shfill").count(), shadings); + assert_eq!(page.matches("closepath clip newpath").count(), 4, "a clip a quadrant"); + } + let out = export(&d, &opts(&d), None, None).unwrap(); + let back = import_with(&out.bytes, false).unwrap().document; + vectorcraft_testkit::raster::assert_similar( + &vectorcraft_testkit::raster::render_artboard(&d), + &vectorcraft_testkit::raster::render_artboard(&back), + 6.0, + 0.01, + ); +} + #[test] fn strokes_keep_their_caps_joins_and_dashes() { let d = doc_with(|d| { diff --git a/crates/js/src/host_tests.rs b/crates/js/src/host_tests.rs index 6fc6c4e04f..6aaa847ceb 100644 --- a/crates/js/src/host_tests.rs +++ b/crates/js/src/host_tests.rs @@ -436,6 +436,104 @@ fn a_marked_host_error_that_passes_through_a_finally_block_says_where_it_was_rai assert_eq!(r.error_mark(), Some(7)); } +/// A realm with a 1 MiB heap, the size that the `finally` blocks of these tests collect garbage in. +fn small_heap_realm() -> Realm { + let mut r = Realm::new(); + r.set_limits(Limits { fuel: 200_000_000, heap_bytes: 1 << 20, ..Limits::default() }); + r +} + +/// Begins a run of `source` and advances it one instruction at a time until a `finally` block runs +/// for an exception whose throw site is saved. +fn start_until_a_throw_site_is_saved(r: &mut Realm, source: &str) { + r.start(source).unwrap(); + while r.interp().finally_sites.is_empty() { + assert_eq!(r.advance(1), Ok(Progress::Paused), "{source}"); + } +} + +/// The collections that a `finally` block's allocations cause under a small heap limit keep the +/// site its exception was raised at, and the run's end leaves no saved site behind. +#[test] +fn an_error_that_passes_through_a_finally_block_that_collects_garbage_says_where_it_was_raised() { + let mut r = small_heap_realm(); + let source = "try {\n throw new Error('original');\n} finally {\n for (var i = 0; i < 20000; i++) { var waste = [i, i + 1, i + 2]; }\n}"; + start_until_a_throw_site_is_saved(&mut r, source); + r.collect_garbage(); + let collections = r.interp().heap.collections; + let e = loop { + match r.advance(1_000) { + Ok(Progress::Paused) => assert_eq!(r.interp().finally_sites.len(), 1, "the site stays saved while the block runs"), + Ok(Progress::Done(v)) => panic!("the run ended with {v:?}"), + Err(e) => break e, + } + }; + assert!(r.interp().heap.collections > collections, "the block's allocations collect garbage"); + assert_eq!((e.kind, e.line, e.column, e.message.as_str()), (RunErrorKind::Thrown, 2, 3, "Error: original")); + assert!(r.interp().finally_sites.is_empty()); +} + +/// The code of a saved throw site stays live through a collection: direct `eval` code that +/// threw is not part of the code of any frame still running. +#[test] +fn a_saved_throw_site_keeps_its_code_live() { + let mut r = small_heap_realm(); + let source = "try {\n eval(\"var unused = 'padding padding padding padding';\\nthrow new Error('original');\");\n} finally {\n for (var i = 0; i < 2000; i++) { var waste = [i]; }\n}"; + start_until_a_throw_site_is_saved(&mut r, source); + let with_site = r.collect_garbage(); + let sites = std::mem::take(&mut r.interp().finally_sites); + let without_site = r.collect_garbage(); + r.interp().finally_sites = sites; + assert_eq!(r.collect_garbage(), with_site, "the saved site's code is counted again"); + assert!(with_site > without_site, "{with_site} <= {without_site}"); + let e = finish(&mut r, 1_000).0.unwrap_err(); + assert_eq!((e.kind, e.line, e.message.as_str()), (RunErrorKind::Thrown, 2, "Error: original")); + assert!(r.interp().finally_sites.is_empty()); +} + +/// A run that a host function stops inside a `finally` block leaves no saved throw site behind. +#[test] +fn a_run_stopped_in_a_finally_block_leaves_no_saved_throw_site() { + let mut r = realm(1_000_000); + let f: HostFunction = Rc::new(|_| Err(HostError::Abort("stopped".into()))); + r.define_host_object("host", vec![("abort".into(), HostItem::Function(f))]).unwrap(); + let e = r.run("try { throw new Error('original'); } finally { host.abort(); }").unwrap_err(); + assert_eq!((e.kind, e.message.as_str()), (RunErrorKind::Limit(LimitKind::Host), "stopped")); + assert!(r.interp().finally_sites.is_empty()); + assert_eq!(r.run("1").unwrap(), JsValue::Number(1.0)); + assert!(r.interp().finally_sites.is_empty()); +} + +/// A `finally` block that `break`, `continue` or `return` leaves drops the saved site of the +/// exception it ran for as it leaves, before the stack grows back over that site's slot. +#[test] +fn leaving_a_finally_block_early_drops_its_saved_throw_site() { + let mut r = realm(1_000_000); + let marked = Rc::new(Cell::new(false)); + let seen = marked.clone(); + let f: HostFunction = Rc::new(move |_| { + seen.set(true); + Ok(Data::Undefined) + }); + r.define_host_object("host", vec![("mark".into(), HostItem::Function(f))]).unwrap(); + for source in [ + "do { try { throw new Error('x'); } finally { break; } } while (false);\nhost.mark(); var a = [1, 2, 3];", + "for (var i = 0; i < 1; i++) { try { throw new Error('x'); } finally { continue; } }\nhost.mark(); var a = [1, 2, 3];", + "function f() { try { throw new Error('x'); } finally { return 1; } }\nf(); host.mark(); var a = [f, f, f];", + ] { + marked.set(false); + let mut saved = false; + r.start(source).unwrap(); + while r.advance(1).unwrap() == Progress::Paused { + saved |= !r.interp().finally_sites.is_empty(); + if marked.get() { + assert!(r.interp().finally_sites.is_empty(), "{source}"); + } + } + assert!(saved && marked.get(), "{source}"); + } +} + #[test] fn advancing_without_a_run_is_an_error() { let mut r = realm(1_000); diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index afb6cde635..cda117ecac 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -235,9 +235,9 @@ pub(crate) struct Interp { /// Where the exceptions `finally` blocks are running for were raised, innermost last, each with /// the stack slot that holds the exception while the block runs. The block's end throws the /// exception on from that site. An entry whose slot is at or above the stack's length belongs to - /// a block that was left, and [`Self::drop_left_finally_sites`] removes it. [`Self::unwind`] - /// removes the entries at or above a handler's slot before it adds one, so the slots rise and - /// the list is never longer than the stack. + /// a block that was left, and [`Self::drop_left_finally_sites`] removes it at each `Jump`, each + /// return and each collection. [`Self::unwind`] removes the entries at or above a handler's slot + /// before it adds one, so the slots rise and the list is never longer than the stack. pub(crate) finally_sites: Vec<(ThrowSite, usize)>, /// The mark of the host error the last run ended with. pub(crate) error_mark: Option, @@ -885,7 +885,11 @@ impl Interp { Op::New(n) => self.op_new(n), Op::Binary(b) => self.op_binary(b), Op::Neg | Op::Plus | Op::BitNot | Op::Not | Op::Typeof | Op::ToNumber => self.op_unary(op), - Op::Jump(t) => self.jump_to(t), + Op::Jump(t) => { + // `break` and `continue` leave a `finally` block by popping its exception and jumping. + self.drop_left_finally_sites(self.stack.len()); + self.jump_to(t) + } Op::JumpIfFalse(t) | Op::JumpIfTrue(t) => self.op_branch(t, matches!(op, Op::JumpIfTrue(_)), false), Op::JumpIfFalseKeep(t) | Op::JumpIfTrueKeep(t) => self.op_branch(t, matches!(op, Op::JumpIfTrueKeep(_)), true), Op::Throw => { diff --git a/crates/js/src/xml.rs b/crates/js/src/xml.rs index 9dc6d71f85..019a9638b0 100644 --- a/crates/js/src/xml.rs +++ b/crates/js/src/xml.rs @@ -175,6 +175,52 @@ fn is_attribute_run(units: &[u16]) -> bool { true } +/// Reads one pseudo-attribute of an XML declaration at `at`: white space, `name`, `=` with +/// optional white space around it, and a quoted value. Returns the offset after the closing +/// quote and the value. +fn decl_attribute<'a>(body: &'a [u16], at: usize, name: &str) -> Option<(usize, &'a [u16])> { + let start = skip_xml_space(body, at); + let name = parser_units(name); + if start == at || !body.get(start..)?.starts_with(&name) { + return None; + } + let eq = skip_xml_space(body, start + name.len()); + if body.get(eq) != Some(&EQ) { + return None; + } + let open = skip_xml_space(body, eq + 1); + let quote = body.get(open).copied().filter(|&q| q == DQUOTE || q == SQUOTE)?; + let len = body.get(open + 1..)?.iter().position(|&c| c == quote)?; + Some((open + len + 2, body.get(open + 1..open + 1 + len)?)) +} + +/// Whether `body`, the text between ``, is an XML declaration's version, optional +/// encoding, optional standalone flag and optional trailing white space (XML 1.0 productions 23 +/// to 26, 32, 80 and 81). +fn is_xml_decl_body(body: &[u16]) -> bool { + let is_digit = |c: &u16| (u16::from(b'0')..=u16::from(b'9')).contains(c); + let is_letter = |c: &u16| (u16::from(b'A')..=u16::from(b'Z')).contains(c) || (u16::from(b'a')..=u16::from(b'z')).contains(c); + let Some((mut at, version)) = decl_attribute(body, 0, "version") else { return false }; + let digits = version.strip_prefix(parser_units("1.").as_slice()).unwrap_or_default(); + if digits.is_empty() || !digits.iter().all(is_digit) { + return false; + } + if let Some((next, encoding)) = decl_attribute(body, at, "encoding") { + let Some((first, rest)) = encoding.split_first() else { return false }; + if !is_letter(first) || !rest.iter().all(|c| is_letter(c) || is_digit(c) || matches!(*c, 0x2D | 0x2E | 0x5F)) { + return false; + } + at = next; + } + if let Some((next, standalone)) = decl_attribute(body, at, "standalone") { + if standalone != parser_units("yes").as_slice() && standalone != parser_units("no").as_slice() { + return false; + } + at = next; + } + skip_xml_space(body, at) == body.len() +} + /// An XML name without a prefix (ECMA-357 section 13.1.2.1). pub(crate) fn is_ncname(units: &[u16]) -> bool { let Some((&first, rest)) = units.split_first() else { return false }; @@ -2305,8 +2351,30 @@ impl Interp { src.get(from..)?.windows(needle.len()).position(|w| w == needle).map(|i| from + i) } + /// Takes fuel for `units` code units that the parser scanned, on top of the fuel of the step + /// that scanned them, so a short run costs nothing extra. + fn xml_scan_fuel(&mut self, units: usize) -> JsResult<()> { + self.xml_take_fuel(units / UNITS_PER_FUEL) + } + + /// The offset after the white space at `src[pos]`, taking fuel for its length. + fn skip_space(&mut self, src: &[u16], pos: usize) -> JsResult { + let end = skip_xml_space(src, pos); + self.xml_scan_fuel(end - pos)?; + Ok(end) + } + + /// The offset after the XML name at `src[pos]`, taking fuel for the units it scanned. fn read_name(&mut self, src: &[u16], pos: usize) -> JsResult { - scan_name(src, pos).ok_or_else(|| self.syntax("invalid XML name")) + if let Some(end) = scan_name(src, pos) { + self.xml_scan_fuel(end - pos)?; + return Ok(end); + } + if src.get(pos).copied().is_some_and(is_name_start) { + let scanned = src.get(pos..).unwrap_or_default().iter().skip(1).take_while(|&&c| is_name_part(c)).count(); + self.xml_scan_fuel(scanned + 1)?; + } + Err(self.syntax("invalid XML name")) } fn check_units(&mut self, src: &[u16], from: usize, to: usize) -> JsResult<()> { @@ -2390,9 +2458,7 @@ impl Interp { let mut raw_attrs: Vec<(usize, usize, Vec)> = Vec::new(); let closes = loop { let before = at; - while src.get(at).copied().is_some_and(is_xml_space) { - at += 1; - } + at = self.skip_space(src, at)?; match src.get(at).copied() { None => return Err(self.syntax("unterminated XML tag")), Some(SLASH) if src.get(at + 1) == Some(>) => { @@ -2407,26 +2473,22 @@ impl Interp { Some(_) => { self.use_fuel()?; let end = self.read_name(src, at)?; - let mut p = end; - while src.get(p).copied().is_some_and(is_xml_space) { - p += 1; - } + let p = self.skip_space(src, end)?; if src.get(p) != Some(&EQ) { return Err(self.syntax("expected '=' after an attribute name")); } - p += 1; - while src.get(p).copied().is_some_and(is_xml_space) { - p += 1; - } + let p = self.skip_space(src, p + 1)?; let quote = match src.get(p).copied() { Some(q @ (DQUOTE | SQUOTE)) => q, _ => return Err(self.syntax("expected a quoted attribute value")), }; let value_start = p + 1; let Some(value_end) = Self::find_from(src, value_start, &[quote]) else { + self.xml_text_fuel(src.len().saturating_sub(value_start))?; return Err(self.syntax("unterminated attribute value")); }; if src.get(value_start..value_end).is_some_and(|v| v.contains(<)) { + self.xml_text_fuel(value_end - value_start)?; return Err(self.syntax("'<' cannot appear in an attribute value")); } let mut value = Vec::new(); @@ -2528,10 +2590,7 @@ impl Interp { fn parse_close_tag(&mut self, src: &[u16], pos: usize, open: &mut Vec, names: &mut Vec<(usize, usize)>) -> JsResult { let name_start = pos + 2; let name_end = self.read_name(src, name_start)?; - let mut at = name_end; - while src.get(at).copied().is_some_and(is_xml_space) { - at += 1; - } + let at = self.skip_space(src, name_end)?; if src.get(at) != Some(>) { return Err(self.syntax("expected '>' at the end of a closing tag")); } @@ -2552,14 +2611,17 @@ impl Interp { held.add(self, string_bytes(source.len()).saturating_mul(2))?; let settings = self.xml_settings()?; let mut src = source; - let mut offset_start = 0; - while src.get(offset_start).copied().is_some_and(is_xml_space) { - offset_start += 1; - } + let offset_start = skip_xml_space(src, 0); if src.get(offset_start..).is_some_and(|r| r.starts_with(&parser_units("")) else { + self.xml_text_fuel(src.len())?; return Err(self.syntax("unterminated XML declaration")); }; + self.xml_text_fuel(end + 2)?; + self.check_units(src, offset_start, end)?; + if !is_xml_decl_body(src.get(offset_start + 5..end).unwrap_or_default()) { + return Err(self.syntax("invalid XML declaration")); + } src = src.get(end + 2..).unwrap_or_default(); } let mut pos = 0; @@ -2573,6 +2635,7 @@ impl Interp { let end = Self::find_from(src, pos, &[LT]).unwrap_or(src.len()); let section_end = parser_units("]]>"); if src.get(pos..end).is_some_and(|run| run.windows(3).any(|w| w == section_end)) { + self.xml_text_fuel(end - pos)?; return Err(self.syntax("']]>' cannot appear in XML text")); } self.decode_into(src, pos, end, &mut text, false)?; @@ -2582,6 +2645,7 @@ impl Interp { let rest = src.get(pos..).unwrap_or_default(); if rest.starts_with(&parser_units("")) else { + self.xml_text_fuel(src.len() - pos)?; return Err(self.syntax("unterminated CDATA section")); }; self.xml_text_fuel(end - pos)?; @@ -2593,12 +2657,13 @@ impl Interp { self.flush_text(&mut text, settings.ignore_whitespace, &open, &mut roots)?; if rest.starts_with(&parser_units("