diff --git a/Cargo.lock b/Cargo.lock index 5faa6567a..9fd8db586 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6338,6 +6338,7 @@ dependencies = [ "memchr", "ruzstd", "serde_json", + "skrifa 0.47.0", "vectorcraft-brush", "vectorcraft-color", "vectorcraft-doc", diff --git a/apps/vectorcraft/src/gpu.rs b/apps/vectorcraft/src/gpu.rs index d7d917ed4..0bf4732ed 100644 --- a/apps/vectorcraft/src/gpu.rs +++ b/apps/vectorcraft/src/gpu.rs @@ -310,7 +310,7 @@ fn pci_ids(device_id: &str) -> Option<(u32, u32)> { let field = |key: &str| { device_id.split(['\\', '&']).find_map(|part| { let (name, hex) = part.split_at_checked(key.len())?; - if name.eq_ignore_ascii_case(key) { u32::from_str_radix(hex, 16).ok() } else { None } + if name.eq_ignore_ascii_case(key) && hex.bytes().all(|b| b.is_ascii_hexdigit()) { u32::from_str_radix(hex, 16).ok() } else { None } }) }; Some((field("VEN_")?, field("DEV_")?)) diff --git a/apps/vectorcraft/src/gpu/tests.rs b/apps/vectorcraft/src/gpu/tests.rs index 40e6a555c..f424280b3 100644 --- a/apps/vectorcraft/src/gpu/tests.rs +++ b/apps/vectorcraft/src/gpu/tests.rs @@ -820,3 +820,10 @@ fn crashes_are_forgotten_in_another_version_or_under_another_preference() { assert_eq!(record.crashed.len(), CRASHED_LIMIT); assert_eq!(record.crashed.last().map(|c| c.key.as_str()), Some("Dx12:10de:0013")); } + +/// `u32::from_str_radix` accepts a leading `+`; a device id with one is not a PCI id. +#[test] +fn pci_ids_reject_a_sign_in_the_hex_digits() { + assert_eq!(pci_ids("PCI\\VEN_+10DE&DEV_2204"), None); + assert_eq!(pci_ids("PCI\\VEN_10DE&DEV_+2204"), None); +} diff --git a/apps/vectorcraft/src/windows_alt_codes.rs b/apps/vectorcraft/src/windows_alt_codes.rs index a4ef28dbb..b370bf214 100644 --- a/apps/vectorcraft/src/windows_alt_codes.rs +++ b/apps/vectorcraft/src/windows_alt_codes.rs @@ -13,6 +13,13 @@ //! same undo step as the typing round it. The keypad digits themselves are taken out of the frame, //! so they don't run a shortcut bound to Alt and a digit. //! +//! With NumLock off the keypad keys arrive as Insert, End, Down, Page Down, Left, Clear, Right, +//! Home, Up and Page Up, and Windows still composes the code from them. They are told from the +//! separate navigation keys by the extended-key flag, which only the separate keys set +//! (), +//! so with Alt held they count as digits and are taken out of the frame too, rather than moving +//! the caret before the character arrives. +//! //! egui reports a keypad digit as the same key as the digit above the letters, so the other //! platforms, whose systems have no such convention, can't tell them apart either. @@ -28,10 +35,20 @@ const WM_CHAR: u32 = 0x0102; const WM_SYSKEYDOWN: u32 = 0x0104; const WM_SYSKEYUP: u32 = 0x0105; const WM_SYSCHAR: u32 = 0x0106; -/// `VK_NUMPAD0` to `VK_NUMPAD9` (`winuser.h`): keypad digits with NumLock on. With it off the keys -/// are Insert, End, … and type no code. +/// `VK_NUMPAD0` to `VK_NUMPAD9` (`winuser.h`): keypad digits with NumLock on. const VK_NUMPAD0: usize = 0x60; const VK_NUMPAD9: usize = 0x69; +/// The keypad's virtual keys with NumLock off, by digit (`winuser.h`): `VK_INSERT`, `VK_END`, +/// `VK_DOWN`, `VK_NEXT`, `VK_LEFT`, `VK_CLEAR`, `VK_RIGHT`, `VK_HOME`, `VK_UP` and `VK_PRIOR`. +const NUMLOCK_OFF_KEYS: [usize; 10] = [0x2D, 0x23, 0x28, 0x22, 0x25, 0x0C, 0x27, 0x24, 0x26, 0x21]; +/// The keys egui reports for the keypad with NumLock off, by digit (Clear has no egui key, so it +/// comes as the top-row digit key, `Num5`). +const NUMLOCK_OFF_EGUI_KEYS: [Key; 10] = + [Key::Insert, Key::End, Key::ArrowDown, Key::PageDown, Key::ArrowLeft, Key::Num5, Key::ArrowRight, Key::Home, Key::ArrowUp, Key::PageUp]; +const DIGIT_KEYS: [Key; 10] = [Key::Num0, Key::Num1, Key::Num2, Key::Num3, Key::Num4, Key::Num5, Key::Num6, Key::Num7, Key::Num8, Key::Num9]; +/// Bit 24 of a key message's `lParam`: an extended key, such as the navigation keys beside the +/// keypad (the keypad's own keys clear it). +const EXTENDED_KEY: isize = 1 << 24; /// `VK_MENU`, `VK_LMENU` and `VK_RMENU`: Alt. const ALT_KEYS: [usize; 3] = [0x12, 0xA4, 0xA5]; @@ -51,23 +68,42 @@ struct AltCodes { high: Option, /// Characters the codes typed, for the next frame. typed: String, - /// Keypad digit presses and releases with Alt held since the last frame, which egui reports - /// as Alt and a digit: taken out of the frame. - keypad_events: usize, + /// The digits of the keypad presses and releases with Alt held since the last frame, which + /// egui reports as Alt and a digit (or, with NumLock off, Alt and a navigation key): taken + /// out of the frame. + keypad_events: Vec, +} + +/// The digit a keypad key stands for in an Alt code: `wparam` is its virtual-key code and +/// `lparam` the message's flags. +fn keypad_digit(wparam: usize, lparam: isize) -> Option { + if (VK_NUMPAD0..=VK_NUMPAD9).contains(&wparam) { + return Some(wparam - VK_NUMPAD0); + } + if lparam & EXTENDED_KEY != 0 { + return None; + } + NUMLOCK_OFF_KEYS.iter().position(|&k| k == wparam) } impl AltCodes { - /// Note a keyboard message `message` with `wparam` (the virtual-key code, or a UTF-16 unit). - fn message(&mut self, message: u32, wparam: usize) { - let keypad = (VK_NUMPAD0..=VK_NUMPAD9).contains(&wparam); - match message { + /// Note a keyboard message `message` with `wparam` (the virtual-key code, or a UTF-16 unit) + /// and `lparam` (its flags). + fn message(&mut self, message: u32, wparam: usize, lparam: isize) { + let keypad = match message { + WM_SYSKEYDOWN | WM_SYSKEYUP => keypad_digit(wparam, lparam), + _ => None, + }; + if let Some(d) = keypad { // Windows sends a key pressed with Alt held as a system key. - WM_SYSKEYDOWN if keypad => { + if message == WM_SYSKEYDOWN { self.digits += 1; - self.keypad_events += 1; self.armed = false; } - WM_SYSKEYUP if keypad => self.keypad_events += 1, + self.keypad_events.push(d); + return; + } + match message { WM_KEYDOWN | WM_SYSKEYDOWN if ALT_KEYS.contains(&wparam) => {} // Any other key ends the sequence and keeps its own meaning. WM_KEYDOWN | WM_SYSKEYDOWN => self.reset(), @@ -99,14 +135,23 @@ impl AltCodes { /// out the keypad digits typed with Alt held. fn drain(&mut self, events: &mut Vec) { let mut keypad = std::mem::take(&mut self.keypad_events); - if keypad > 0 { + if !keypad.is_empty() { events.retain(|e| { - let digit = matches!(e, Event::Key { key: Key::Num0 | Key::Num1 | Key::Num2 | Key::Num3 | Key::Num4 | Key::Num5 | Key::Num6 | Key::Num7 | Key::Num8 | Key::Num9, modifiers, .. } if modifiers.alt); - if digit && keypad > 0 { - keypad -= 1; - return false; + let Event::Key { key, modifiers, .. } = e else { + return true; + }; + if !modifiers.alt { + return true; + } + // The first keypad key still to take out that egui reports as `key`. + let stands_for = |d: &usize| DIGIT_KEYS.get(*d) == Some(key) || NUMLOCK_OFF_EGUI_KEYS.get(*d) == Some(key); + match keypad.iter().position(stands_for) { + Some(i) => { + keypad.remove(i); + false + } + None => true, } - true }); } if !self.typed.is_empty() { @@ -129,7 +174,7 @@ pub fn msg_hook(msg: *const std::ffi::c_void) { if (WM_KEYDOWN..=WM_SYSCHAR).contains(&msg.message) { CODES.with(|c| { if let Ok(mut c) = c.try_borrow_mut() { - c.message(msg.message, msg.wParam.0); + c.message(msg.message, msg.wParam.0, msg.lParam.0); } }); } @@ -152,14 +197,14 @@ mod tests { /// Alt held, keypad digits `code`, Alt released, and the character Windows then posts. fn alt_code(c: &mut AltCodes, code: &str, posted: u16) { - c.message(WM_SYSKEYDOWN, ALT); + c.message(WM_SYSKEYDOWN, ALT, 0); for d in code.bytes() { let vk = VK_NUMPAD0 + usize::from(d - b'0'); - c.message(WM_SYSKEYDOWN, vk); - c.message(WM_SYSKEYUP, vk); + c.message(WM_SYSKEYDOWN, vk, 0); + c.message(WM_SYSKEYUP, vk, 0); } - c.message(WM_KEYUP, ALT); - c.message(WM_CHAR, usize::from(posted)); + c.message(WM_KEYUP, ALT, 0); + c.message(WM_CHAR, usize::from(posted), 0); } fn key(key: Key, alt: bool, pressed: bool) -> Event { @@ -186,11 +231,11 @@ mod tests { fn a_character_without_a_code_before_it_is_left_to_winit() { let mut c = AltCodes::default(); // A plain key press's character (winit types it), and Alt pressed and released alone. - c.message(WM_KEYDOWN, 0x41); - c.message(WM_CHAR, usize::from(b'a')); - c.message(WM_SYSKEYDOWN, ALT); - c.message(WM_KEYUP, ALT); - c.message(WM_CHAR, usize::from(b'b')); + c.message(WM_KEYDOWN, 0x41, 0); + c.message(WM_CHAR, usize::from(b'a'), 0); + c.message(WM_SYSKEYDOWN, ALT, 0); + c.message(WM_KEYUP, ALT, 0); + c.message(WM_CHAR, usize::from(b'b'), 0); let mut events = vec![]; c.drain(&mut events); assert!(events.is_empty(), "{events:?}"); @@ -199,12 +244,12 @@ mod tests { #[test] fn another_key_while_alt_is_held_cancels_the_code() { let mut c = AltCodes::default(); - c.message(WM_SYSKEYDOWN, ALT); - c.message(WM_SYSKEYDOWN, VK_NUMPAD0 + 1); + c.message(WM_SYSKEYDOWN, ALT, 0); + c.message(WM_SYSKEYDOWN, VK_NUMPAD0 + 1, 0); // Alt+F: a shortcut, not a code. - c.message(WM_SYSKEYDOWN, 0x46); - c.message(WM_KEYUP, ALT); - c.message(WM_CHAR, 0x41); + c.message(WM_SYSKEYDOWN, 0x46, 0); + c.message(WM_KEYUP, ALT, 0); + c.message(WM_CHAR, 0x41, 0); let mut events = vec![]; c.drain(&mut events); assert!(!events.iter().any(|e| matches!(e, Event::Text(_))), "{events:?}"); @@ -213,13 +258,81 @@ mod tests { #[test] fn a_code_above_the_basic_plane_arrives_as_two_halves() { let mut c = AltCodes::default(); - c.message(WM_SYSKEYDOWN, ALT); - c.message(WM_SYSKEYDOWN, VK_NUMPAD0 + 1); - c.message(WM_KEYUP, ALT); - c.message(WM_CHAR, 0xD83D); - c.message(WM_CHAR, 0xDE00); + c.message(WM_SYSKEYDOWN, ALT, 0); + c.message(WM_SYSKEYDOWN, VK_NUMPAD0 + 1, 0); + c.message(WM_KEYUP, ALT, 0); + c.message(WM_CHAR, 0xD83D, 0); + c.message(WM_CHAR, 0xDE00, 0); let mut events = vec![]; c.drain(&mut events); assert_eq!(events, vec![Event::Text("😀".into())]); } + + /// Alt held, keypad digits `code` with NumLock off (keypad Insert, End, …, flags without the + /// extended-key bit), Alt released, and the character Windows then posts. + fn alt_code_numlock_off(c: &mut AltCodes, code: &str, posted: u16) { + c.message(WM_SYSKEYDOWN, ALT, 0); + for d in code.bytes() { + let vk = NUMLOCK_OFF_KEYS[usize::from(d - b'0')]; + c.message(WM_SYSKEYDOWN, vk, 0); + c.message(WM_SYSKEYUP, vk, 0); + } + c.message(WM_KEYUP, ALT, 0); + c.message(WM_CHAR, usize::from(posted), 0); + } + + #[test] + fn with_numlock_off_the_keypad_keys_type_the_code_and_leave_the_caret_alone() { + let mut c = AltCodes::default(); + alt_code_numlock_off(&mut c, "0176", 0xB0); + // What egui reports for keypad 0, 1, 7 and 6 with NumLock off and Alt held, a press and a + // release each, then a plain Home typed after Alt came up. + let mut events = vec![]; + for k in [Key::Insert, Key::End, Key::Home, Key::ArrowRight] { + events.push(key(k, true, true)); + events.push(key(k, true, false)); + } + events.push(key(Key::Home, false, true)); + c.drain(&mut events); + assert_eq!(events, vec![key(Key::Home, false, true), Event::Text("°".into())]); + } + + #[test] + fn with_numlock_off_keypad_five_counts_too() { + let mut c = AltCodes::default(); + // Keypad 5 is Clear, which egui reports as the top-row 5. 0151 uses Insert, End and Clear; + // 0234 uses Insert, Down, Page Down and Left. + alt_code_numlock_off(&mut c, "0151", 0x2014); + alt_code_numlock_off(&mut c, "0234", 0xEA); + let mut events = + vec![key(Key::Num5, true, true), key(Key::ArrowDown, true, true), key(Key::PageDown, true, true), key(Key::ArrowLeft, true, true)]; + c.drain(&mut events); + assert_eq!(events, vec![Event::Text("—ê".into())]); + } + + #[test] + fn the_separate_navigation_keys_still_move_the_caret_and_cancel_the_code() { + let mut c = AltCodes::default(); + c.message(WM_SYSKEYDOWN, ALT, 0); + c.message(WM_SYSKEYDOWN, NUMLOCK_OFF_KEYS[1], 0); + c.message(WM_SYSKEYUP, NUMLOCK_OFF_KEYS[1], 0); + // Alt+Home on the separate Home key (extended): a shortcut, not a digit. + c.message(WM_SYSKEYDOWN, NUMLOCK_OFF_KEYS[7], EXTENDED_KEY); + c.message(WM_SYSKEYUP, NUMLOCK_OFF_KEYS[7], EXTENDED_KEY); + c.message(WM_KEYUP, ALT, 0); + c.message(WM_CHAR, 0x41, 0); + let mut events = vec![key(Key::End, true, true), key(Key::End, true, false), key(Key::Home, true, true), key(Key::Home, true, false)]; + c.drain(&mut events); + assert_eq!(events, vec![key(Key::Home, true, true), key(Key::Home, true, false)]); + } + + #[test] + fn numlock_off_keys_without_alt_are_left_alone() { + let mut c = AltCodes::default(); + c.message(WM_KEYDOWN, NUMLOCK_OFF_KEYS[7], 0); + c.message(WM_KEYUP, NUMLOCK_OFF_KEYS[7], 0); + let mut events = vec![key(Key::Home, false, true), key(Key::Home, false, false)]; + c.drain(&mut events); + assert_eq!(events.len(), 2); + } } diff --git a/crates/affinity/src/model/read.rs b/crates/affinity/src/model/read.rs index 00910baab..80f4e0993 100644 --- a/crates/affinity/src/model/read.rs +++ b/crates/affinity/src/model/read.rs @@ -29,6 +29,14 @@ 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. @@ -44,6 +52,10 @@ pub(crate) struct Budget { 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 { @@ -53,7 +65,15 @@ impl Budget { 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. @@ -192,6 +212,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { 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, @@ -214,7 +235,10 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { let s = self.s; let mut out = Vec::new(); let mut mask = None; - for c in s.objs(parent, b"Chld") { + 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) { @@ -254,7 +278,8 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { 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).collect(); + 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)); @@ -312,6 +337,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { 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 } } @@ -645,3 +671,15 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { 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/paint.rs b/crates/affinity/src/paint.rs index 12102581e..c7b2acbbd 100644 --- a/crates/affinity/src/paint.rs +++ b/crates/affinity/src/paint.rs @@ -238,12 +238,13 @@ pub(crate) fn descriptor(r: &mut Reader, id: ObjId, local: Affine) -> Option> = match s.field(grad, b"Posn") { - Some(Value::Array(a)) => a.iter().filter_map(floats).collect(), - _ => Vec::new(), + // 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).take(MAX_STOPS) { + 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); diff --git a/crates/affinity/src/raster.rs b/crates/affinity/src/raster.rs index d458c08a7..bb8c1c88b 100644 --- a/crates/affinity/src/raster.rs +++ b/crates/affinity/src/raster.rs @@ -195,6 +195,11 @@ fn decode_within(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), worl 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)), @@ -318,8 +323,10 @@ fn grid(s: &stream::Stream, bitmap: ObjId, c: usize, bps: usize) -> (usize, usiz 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().map(|v| if let Value::UInt(u) = v { u8::try_from(*u).unwrap_or(255) } else { 255 }).collect(), + 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) diff --git a/crates/affinity/src/text.rs b/crates/affinity/src/text.rs index 1a3b6e544..d2bb55ca0 100644 --- a/crates/affinity/src/text.rs +++ b/crates/affinity/src/text.rs @@ -51,6 +51,9 @@ 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; @@ -108,9 +111,10 @@ pub(crate) fn table(r: &mut Reader, id: ObjId, world: Affine) -> Option
{ 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) => cell.runs.push(TextRun { text: part.to_string(), ..run.clone() }), - None => extra = true, + 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() { @@ -157,6 +161,13 @@ pub(crate) fn story_runs(r: &mut Reader, story: ObjId, world: Affine) -> (Vec (Vec = Vec::new(); let mut open: Option = None; - for run in s.obj(block, b"PAtt").map(|p| s.objs(p, b"Runs")).unwrap_or_default() { + 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); @@ -238,7 +249,7 @@ pub(crate) fn story_runs(r: &mut Reader, story: ObjId, world: Affine) -> (Vec Scene<'a> { let Some(&first) = run.first() else { continue }; let lo = run.iter().map(|g| g.byte).min().unwrap_or(first.byte); let hi = run.iter().map(|g| g.byte + g.len).max().unwrap_or(lo); - // A right-to-left paragraph's marker is drawn with its tab first and its number's - // period at the number's left; the TEXT entity holds the characters in that order. + // A right-to-left paragraph's marker is drawn with its number's period at the + // number's left; the TEXT entity holds the characters in that order. let marker = chunk.first().and_then(|(_, _, m)| *m).map(|m| match m.text.strip_suffix('.') { - Some(number) if m.tab == m.glyphs.start => format!(".{number}"), + Some(number) if layout.glyphs.get(m.tab).is_some_and(|g| g.rtl) => format!(".{number}"), _ => m.text.clone(), }); let Some(s) = marker.as_deref().or_else(|| plain.get(lo..hi).map(str::trim_end)).filter(|s| !s.trim().is_empty()) else { continue }; diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index 5c57fa634..90e4cbc2c 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -1076,15 +1076,15 @@ fn a_bullet_is_text_of_its_own_before_its_paragraph() { /// A numbered list paragraph's number and period are a TEXT entity of their own where they are /// drawn, in the order they are drawn: in a right-to-left paragraph the period comes first, at the -/// number's left. +/// number's left, with the marker aligned left or right. #[test] fn a_number_is_text_of_its_own_before_its_paragraph() { - use vectorcraft_doc::text::{NumberStyle, ParaList}; - let numbered = |text: &str, count: u32| { + use vectorcraft_doc::text::{ListAlign, NumberStyle, ParaList}; + let numbered_aligned = |text: &str, count: u32, align: ListAlign| { let mut d = text_doc(text, 20.0); let id = d.layers[0].children().unwrap()[0].id; if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) { - t.para.list = Some(ParaList::numbered(NumberStyle::Arabic)); + t.para.list = Some(ParaList::numbered(NumberStyle::Arabic).aligned(align)); t.para.left_indent = 30.0; t.para.first_line_indent = -30.0; t.list_count = count; @@ -1092,10 +1092,27 @@ fn a_number_is_text_of_its_own_before_its_paragraph() { let out = export(&d, &DxfOptions { version: DxfVersion::R2018, preserve: Preserve::Editability, ..opts(&d) }).unwrap(); entities(&out).iter().map(|e| (get(e, 1).map(str::to_string), all(e, 10)[0])).collect::>() }; + let numbered = |text: &str, count: u32| numbered_aligned(text, count, ListAlign::Left); assert_eq!(numbered("Item", 11), [(Some("12.".to_string()), 10.0), (Some("Item".to_string()), 40.0)]); let rtl = numbered("שלום", 11); assert_eq!(rtl.iter().map(|(s, _)| s.clone().unwrap_or_default()).collect::>(), ["שלום", ".12"]); assert!(rtl[1].1 > rtl[0].1, "the number right of the text: {rtl:?}"); + // Aligned right, the number keeps its order and moves right, against the text. + let right = numbered_aligned("Item", 11, ListAlign::Right); + assert_eq!((right[0].0.as_deref(), right[1].clone()), (Some("12."), (Some("Item".to_string()), 40.0))); + assert!(right[0].1 > 10.0 && right[0].1 < 40.0, "{right:?}"); + let rtl = numbered_aligned("שלום", 11, ListAlign::Right); + assert_eq!(rtl.iter().map(|(s, _)| s.clone().unwrap_or_default()).collect::>(), ["שלום", ".12"]); + // A mixed paragraph: the period follows the paragraph's direction, not the script of the + // words it holds, with either alignment. + let marker = |text: &str, align: ListAlign| { + let out = numbered_aligned(text, 11, align); + out.into_iter().filter_map(|(s, _)| s).find(|s| s.contains("12")).unwrap_or_default() + }; + for align in [ListAlign::Left, ListAlign::Right] { + assert_eq!(marker("Item שלום", align), "12.", "{align:?}"); + assert_eq!(marker("שלום Item", align), ".12", "{align:?}"); + } } /// Area type that Shrink Text to Fit scales down is written at the height it draws at. diff --git a/crates/color/src/cms/generic.rs b/crates/color/src/cms/generic.rs index 95abdf2f3..3d532ac02 100644 --- a/crates/color/src/cms/generic.rs +++ b/crates/color/src/cms/generic.rs @@ -1,19 +1,26 @@ //! "VectorCraft Generic CMYK (SWOP-like)": our own parametric press model. //! //! We can't redistribute Adobe's or ECI's characterisation profiles, so the built-in CMYK space is -//! a small physical model instead of an ICC LUT. It is *ours* and only SWOP-*like*: the numbers -//! are rounded, typical values for coated web offset, not a measured dataset. +//! a small physical model instead of an ICC LUT. It is *ours* and only SWOP-*like*. Two of its +//! figures are published values for SWOP printing: the tone value increase of each ink at 40% and +//! 50% from CGATS TR 001 (), and the 300% +//! total ink limit (). +//! The paper, solid and overprint colors and the black-generation curve are VectorCraft's own values. //! -//! * **Forward (CMYK → XYZ):** each ink's nominal dot area is widened by a parabolic dot-gain -//! curve (`a + 4·g·a·(1−a)`, i.e. `g` gain at 50%); the effective areas mix the eight CMY -//! Neugebauer primaries with Demichel weights; black is mixed in the same way against -//! "primary + K" colours whose optical densities add but saturate towards a maximum density -//! (normalised so K on paper is exact), so rich blacks don't multiply to an unrealistic depth. +//! * **Forward (CMYK → XYZ):** each ink's nominal dot area `a` is widened by a tone curve +//! `a + 4·g·a·(1−a)·(1 + s·(2a−1))` that passes through the TR 001 tone value increase at 50% +//! (`g`) and 40% (which sets `s`). The effective areas mix the 16 Neugebauer primaries (paper and +//! every solid and overprint of C, M, Y and K, media-relative) with Demichel weights, linearly in +//! XYZ, so a single ink's colorimetric tone value increase (Murray–Davies) is the TR 001 value. //! * **Inverse (Lab → CMYK):** damped, bounded Gauss–Newton on the forward model solves CMY for a -//! fixed K. Black generation is GCR: solve CMY with K = 0, take the grey component -//! `min(C, M, Y)`, map it through the black-generation curve to K, re-solve CMY. Under-colour +//! fixed K. Black generation is GCR: solve CMY with K = 0, take the gray component +//! `min(C, M, Y)`, add [`BlackGeneration::shortfall`] for each L* point by which that solve stays +//! lighter than the target, map the sum through the black curve to K, re-solve CMY. Under-color //! removal enforces the total area coverage (TAC) limit by adding K and re-solving, then scaling -//! CMY as a last resort. +//! CMY as a last resort. The black point, which source black separates to and which displays as +//! black with black-point compensation, is [`BlackGeneration::black_point_k`] black with neutral +//! CMY filling the TAC. With compensation, a build darker than it in every XYZ channel also +//! displays as black (see [`GenericCmyk::display_xyz`]). //! * **Rendering intents:** colorimetric intents map the source white to paper (relative) or not //! (absolute) and clip; perceptual applies black-point compensation plus soft chroma compression //! against a gamut boundary descriptor (segment maxima of the forward model); saturation keeps @@ -24,50 +31,82 @@ use std::sync::OnceLock; use super::Intent; use super::lab::{D50, Lab, delta_e2000, lab_to_xyz, srgb_to_lab, xyz_to_lab, xyz_to_srgb}; -/// Black generation strength. +/// Black generation. #[derive(Clone, Copy, Debug, PartialEq)] pub struct BlackGeneration { - /// Grey component below which no black is generated. - pub start: f32, - /// Maximum black. - pub max_k: f32, - /// Curve exponent (>1 = lighter black in the midtones). - pub gamma: f32, + /// The black curve as `(x, K)` knots with rising `x`, read by linear interpolation. `x` is the + /// gray component of the solve without black plus [`Self::shortfall`] per L* point by which + /// that solve stays lighter than the target. + pub curve: [(f32, f32); 13], + /// Added to the gray component for each L* point the black-free solve stays too light. + pub shortfall: f32, + /// Black of the black point, whose CMY is neutral and fills the TAC. + pub black_point_k: f32, } /// Parameters of the parametric press model. #[derive(Clone, Debug)] pub struct GenericCmykParams { pub name: &'static str, + /// Paper color (absolute Lab, D50). pub paper: Lab, - /// Solid overprints, Demichel order: C, M, CM, Y, CY, MY, CMY. - pub solids: [Lab; 7], - pub black: Lab, - /// Dot gain at 50% per ink (C, M, Y, K). - pub dot_gain: [f32; 4], + /// Media-relative Lab of the solids and overprints, Demichel order (index `c | m<<1 | y<<2 | + /// k<<3`, paper left out): C, M, CM, Y, CY, MY, CMY, then K, CK, MK, CMK, YK, CYK, MYK, CMYK. + pub primaries: [Lab; 15], + /// Tone value increase at 50% per ink (C, M, Y, K). + pub tvi50: [f32; 4], + /// Tone value increase at 40% per ink (C, M, Y, K). + pub tvi40: [f32; 4], /// Total area coverage limit (sum of inks, 1.0 = 100%). pub tac: f32, pub bg: BlackGeneration, } -/// Rounded, typical SWOP-like values (not measured data). +/// The built-in SWOP-like press: TR 001 tone value increase, a 300% ink limit, and VectorCraft's +/// own paper, primaries and black curve. pub fn swop_like() -> GenericCmykParams { GenericCmykParams { name: super::GENERIC_CMYK, paper: Lab::new(93.0, -0.5, 2.5), - solids: [ - Lab::new(55.0, -37.0, -50.0), // C - Lab::new(48.0, 74.0, -3.0), // M - Lab::new(24.0, 22.0, -46.0), // CM (blue) - Lab::new(89.0, -5.0, 93.0), // Y - Lab::new(50.0, -65.0, 27.0), // CY (green) - Lab::new(47.0, 68.0, 48.0), // MY (red) - Lab::new(24.0, 1.0, 0.0), // CMY + primaries: [ + Lab::new(65.7, -42.6, -47.2), // C + Lab::new(55.5, 76.2, -9.4), // M + Lab::new(31.1, 20.3, -49.7), // CM (blue) + Lab::new(96.2, -7.0, 90.1), // Y + Lab::new(61.0, -68.4, 30.1), // CY (green) + Lab::new(55.0, 69.8, 45.0), // MY (red) + Lab::new(28.3, 0.7, -0.5), // CMY + Lab::new(21.5, 0.8, -0.9), // K + Lab::new(15.9, -8.4, -10.0), // CK + Lab::new(14.7, 15.4, -1.0), // MK + Lab::new(11.2, 8.9, -6.6), // CMK + Lab::new(20.2, -2.4, 12.0), // YK + Lab::new(15.7, -14.6, 4.6), // CYK + Lab::new(14.5, 11.2, 5.7), // MYK + Lab::new(11.0, -0.7, 0.1), // CMYK ], - black: Lab::new(19.0, 0.5, 0.5), - dot_gain: [0.20, 0.20, 0.20, 0.22], + tvi50: [0.200, 0.176, 0.168, 0.216], + tvi40: [0.200, 0.173, 0.167, 0.219], tac: 3.0, - bg: BlackGeneration { start: 0.1, max_k: 0.95, gamma: 1.25 }, + bg: BlackGeneration { + curve: [ + (0.0, 0.0), + (0.25, 0.0), + (0.35, 0.006), + (0.45, 0.059), + (0.55, 0.139), + (0.65, 0.23), + (0.75, 0.322), + (0.85, 0.41), + (0.95, 0.511), + (1.05, 0.6), + (1.2, 0.733), + (1.35, 0.858), + (1.5, 1.0), + ], + shortfall: 0.03, + black_point_k: 0.9, + }, } } @@ -83,11 +122,12 @@ const GBD_H: usize = 72; // 5° bins pub struct GenericCmyk { pub params: GenericCmykParams, - /// XYZ of the 8 CMY primaries (index = c | m<<1 | y<<2), without and with K. - prim: [[f32; 3]; 8], - prim_k: [[f32; 3]; 8], + /// Media-relative XYZ of the 16 primaries (index = c | m<<1 | y<<2 | k<<3; 0 is paper). + prim: [[f32; 3]; 16], + /// Shape `s` of each ink's tone curve, from its 40% and 50% tone value increase. + skew: [f32; 4], paper_xyz: [f32; 3], - /// Media-relative XYZ of the darkest printable black (for black-point compensation). + /// Media-relative XYZ of the black point (for black-point compensation). black_rel: [f32; 3], gbd: OnceLock, } @@ -95,37 +135,17 @@ pub struct GenericCmyk { impl GenericCmyk { pub fn new(params: GenericCmykParams) -> Self { let paper_xyz = lab_to_xyz(params.paper); - let mut prim = [[0.0f32; 3]; 8]; - prim[0] = paper_xyz; - for (p, lab) in prim[1..].iter_mut().zip(params.solids.iter()) { - *p = lab_to_xyz(*lab); + let mut prim = [D50; 16]; + for (p, lab) in prim[1..].iter_mut().zip(params.primaries.iter()) { + *p = lab_to_xyz(*lab).map(|v| v.max(1e-6)); } - let k_xyz = lab_to_xyz(params.black); - // Black over other inks: optical densities add, but saturate towards a maximum density - // (ink trapping / surface reflection), normalised so that K on paper is exact. - const D_MAX: f32 = 2.0; - let sat = |d: f32| D_MAX * (1.0 - (-d / D_MAX).exp()); - let mut prim_k = [[0.0f32; 3]; 8]; - for i in 0..8 { - for ch in 0..3 { - let di = -(prim[i][ch] / paper_xyz[ch]).max(1e-6).log10(); - let dk = -(k_xyz[ch] / paper_xyz[ch]).max(1e-6).log10(); - let d = sat(di + dk) * dk / sat(dk).max(1e-6); - prim_k[i][ch] = paper_xyz[ch] * 10f32.powf(-d.max(di)); - } - } - let mut g = Self { params, prim, prim_k, paper_xyz, black_rel: [0.0; 3], gbd: OnceLock::new() }; - // Darkest black within TAC: search a few rich blacks. - let mut best = [1.0f32; 3]; - for k in [0.8f32, 0.9, 1.0] { - let rest = (g.params.tac - k).max(0.0) / 3.0; - let cmy = rest.min(1.0); - let x = g.to_xyz_rel([cmy, cmy, cmy, k]); - if x[1] < best[1] { - best = x; - } - } - g.black_rel = best; + // The curve's increase at 40% is 0.96·g·(1 − 0.2·s), so `s` follows from the 40% value. + let skew = [0, 1, 2, 3].map(|i| { + let g = params.tvi50[i]; + if g > 1e-6 { (1.0 - params.tvi40[i] / (0.96 * g)) / 0.2 } else { 0.0 } + }); + let mut g = Self { params, prim, skew, paper_xyz, black_rel: [0.0; 3], gbd: OnceLock::new() }; + g.black_rel = g.to_xyz_rel(g.black_point_build()); g } @@ -133,35 +153,81 @@ impl GenericCmyk { self.params.name } - fn gain(a: f32, g: f32) -> f32 { - let a = a.clamp(0.0, 1.0); - (a + 4.0 * g * a * (1.0 - a)).clamp(0.0, 1.0) + /// The black point: [`BlackGeneration::black_point_k`] black, with C, M and Y + /// chosen so that they fill the TAC and the color is neutral (a* = b* = 0). + pub fn black_point_build(&self) -> [f32; 4] { + let k = self.params.bg.black_point_k.clamp(0.0, 1.0); + let rest = (self.params.tac - k).clamp(0.0, 3.0); + let build = |c: f32, m: f32| [c, m, (rest - c - m).clamp(0.0, 1.0), k]; + let ab = |c: f32, m: f32| { + let l = self.to_lab_rel(build(c, m)); + [l.a, l.b] + }; + let lo = (rest - 2.0).max(0.0); + let hi = rest.min(1.0); + let (mut c, mut m) = ((rest / 3.0).clamp(lo, hi), (rest / 3.0).clamp(lo, hi)); + for _ in 0..30 { + let r = ab(c, m); + if r[0].abs() + r[1].abs() < 1e-4 { + break; + } + let h = 1e-3; + let rc = ab(c - h, m); + let rm = ab(c, m - h); + let j = [[(r[0] - rc[0]) / h, (r[0] - rm[0]) / h], [(r[1] - rc[1]) / h, (r[1] - rm[1]) / h]]; + let det = j[0][0] * j[1][1] - j[0][1] * j[1][0]; + if det.abs() < 1e-9 { + break; + } + let dc = (-r[0] * j[1][1] + r[1] * j[0][1]) / det; + let dm = (-r[1] * j[0][0] + r[0] * j[1][0]) / det; + c = (c + dc.clamp(-0.1, 0.1)).clamp(lo, hi); + m = (m + dm.clamp(-0.1, 0.1)).clamp(lo, hi); + // Yellow takes the rest, so keep it within 0..1 as well. + let y = rest - c - m; + if y > 1.0 { + c += (y - 1.0) / 2.0; + m += (y - 1.0) / 2.0; + } else if y < 0.0 { + c += y / 2.0; + m += y / 2.0; + } + c = c.clamp(lo, hi); + m = m.clamp(lo, hi); + } + build(c.clamp(lo, hi), m.clamp(lo, hi)) + } + + /// Effective dot area of ink `i` at nominal area `a`: the TR 001-style tone curve. + fn gain(&self, i: usize, a: f32) -> f32 { + let a = if a.is_finite() { a.clamp(0.0, 1.0) } else { 0.0 }; + let g = self.params.tvi50[i]; + (a + 4.0 * g * a * (1.0 - a) * (1.0 + self.skew[i] * (2.0 * a - 1.0))).clamp(0.0, 1.0) } /// CMYK (0..1) → absolute XYZ (D50). pub fn to_xyz(&self, cmyk: [f32; 4]) -> [f32; 3] { - let dg = self.params.dot_gain; - let c = Self::gain(cmyk[0], dg[0]); - let m = Self::gain(cmyk[1], dg[1]); - let y = Self::gain(cmyk[2], dg[2]); - let k = Self::gain(cmyk[3], dg[3]); - let mut out = [0.0f32; 3]; - for i in 0..8 { - let w = (if i & 1 != 0 { c } else { 1.0 - c }) * (if i & 2 != 0 { m } else { 1.0 - m }) * (if i & 4 != 0 { y } else { 1.0 - y }); - if w == 0.0 { - continue; - } - for (ch, o) in out.iter_mut().enumerate() { - *o += w * ((1.0 - k) * self.prim[i][ch] + k * self.prim_k[i][ch]); - } - } - out + let x = self.to_xyz_rel(cmyk); + [x[0] * self.paper_xyz[0] / D50[0], x[1] * self.paper_xyz[1] / D50[1], x[2] * self.paper_xyz[2] / D50[2]] } /// Media-relative XYZ (paper = D50 white). pub fn to_xyz_rel(&self, cmyk: [f32; 4]) -> [f32; 3] { - let x = self.to_xyz(cmyk); - [x[0] * D50[0] / self.paper_xyz[0], x[1] * D50[1] / self.paper_xyz[1], x[2] * D50[2] / self.paper_xyz[2]] + let e = [0, 1, 2, 3].map(|i| self.gain(i, cmyk[i])); + let mut out = [0.0f32; 3]; + for (i, p) in self.prim.iter().enumerate() { + let mut w = 1.0; + for (bit, a) in e.iter().enumerate() { + w *= if i & (1 << bit) != 0 { *a } else { 1.0 - a }; + } + if w == 0.0 { + continue; + } + for (o, v) in out.iter_mut().zip(p) { + *o += w * v; + } + } + out } pub fn to_lab_rel(&self, cmyk: [f32; 4]) -> Lab { @@ -174,7 +240,9 @@ impl GenericCmyk { /// Display XYZ: media-relative (paper shows as white) or absolute (simulate paper colour). /// With `bpc` (media-relative only) the press black displays as black, undoing the /// compensation [`Self::gamut_map`] applies on the way in, so sRGB black separated to the - /// darkest rich black shows as black again. + /// black point shows as black again. Each XYZ channel is clamped at 0 on its own, so a build + /// darker than the black point in every channel shows as black, and one darker in only some + /// channels shows as a colored near-black. pub fn display_xyz(&self, cmyk: [f32; 4], absolute: bool, bpc: bool) -> [f32; 3] { if absolute { return self.to_xyz(cmyk); @@ -192,7 +260,7 @@ impl GenericCmyk { xyz_to_srgb(self.display_xyz(cmyk, absolute, bpc)) } - /// Lab of the darkest printable black (media-relative). + /// Lab of the black point (media-relative), see [`Self::black_point_build`]. pub fn black_point(&self) -> Lab { xyz_to_lab(self.black_rel) } @@ -283,12 +351,21 @@ impl GenericCmyk { rgb.map(|v| ((1.0 - v - k) / d).clamp(0.0, 1.0)) } - fn black_for_grey(&self, grey: f32) -> f32 { - let bg = self.params.bg; - if grey <= bg.start { - return 0.0; + /// Black for `x` on the black curve (see [`BlackGeneration::curve`]). + fn black_for(&self, x: f32) -> f32 { + let curve = &self.params.bg.curve; + let mut prev = curve[0]; + if x <= prev.0 { + return prev.1.clamp(0.0, 1.0); } - (((grey - bg.start) / (1.0 - bg.start)).clamp(0.0, 1.0).powf(bg.gamma) * bg.max_k).clamp(0.0, 1.0) + for &(kx, kk) in &curve[1..] { + if x <= kx { + let t = if kx > prev.0 { (x - prev.0) / (kx - prev.0) } else { 1.0 }; + return (prev.1 + (kk - prev.1) * t).clamp(0.0, 1.0); + } + prev = (kx, kk); + } + prev.1.clamp(0.0, 1.0) } /// Separate a media-relative Lab (already gamut-mapped) into CMYK with GCR/UCR and the TAC limit. @@ -297,13 +374,10 @@ impl GenericCmyk { return [0.0; 4]; } let cmy0 = self.solve_cmy(target, 0.0, Self::initial_cmy(target, 0.0)); - let grey = cmy0[0].min(cmy0[1]).min(cmy0[2]); - let mut k = self.black_for_grey(grey); - // If CMY alone can't get dark enough, black must carry the rest. + let gray = cmy0[0].min(cmy0[1]).min(cmy0[2]); + // When CMY alone can't get dark enough, black carries the rest. let l0 = self.to_lab_rel([cmy0[0], cmy0[1], cmy0[2], 0.0]).l; - if l0 > target.l + 1.0 { - k = k.max(((l0 - target.l) / l0.max(1.0)).clamp(0.0, 1.0)); - } + let mut k = self.black_for(gray + self.params.bg.shortfall * (l0 - target.l).max(0.0)); let mut cmy = if k > 0.0 { self.solve_cmy(target, k, Self::initial_cmy(target, k)) } else { cmy0 }; // UCR: trade CMY for K until within the TAC limit. for _ in 0..6 { diff --git a/crates/color/src/cms/tests.rs b/crates/color/src/cms/tests.rs index ecb4c1c59..57709abe6 100644 --- a/crates/color/src/cms/tests.rs +++ b/crates/color/src/cms/tests.rs @@ -245,8 +245,8 @@ fn loads_system_cmyk_icc() { assert_eq!(c.cmyk_to_srgb([0.0; 4], false).map(|v| (v * 255.0).round() as u8), [255, 255, 255]); } -/// With black-point compensation, sRGB black separates to the press's darkest black and that black -/// displays as black again (hex 000000 -> C67 M67 Y67 K100 -> 000000, not a dark grey). +/// With black-point compensation, sRGB black separates to the press's black point and that black +/// displays as black again (hex 000000 -> C75 M68 Y67 K90 -> 000000, not a dark gray). #[test] fn generic_cmyk_black_round_trips_with_bpc() { let c = cms(); @@ -259,25 +259,104 @@ fn generic_cmyk_black_round_trips_with_bpc() { assert!(raw.iter().all(|v| *v > 0.05), "{raw:?}"); } -/// A color whose solve holds cyan at 0 separates to the same inks when its values change by a few -/// float steps: hex ed1e79 with its red stepped one `f32` at a time over 4,001 values (about 0.00024 -/// in all) stays within 0.001 on every ink. +/// Each ink's 40% and 50% tints gain like CGATS TR 001 (SWOP): the colorimetric tone value increase +/// (Murray–Davies on X for cyan, Y for magenta and black, Z for yellow) is C 20.0, M 17.6, Y 16.8 and +/// K 21.6 points at 50%, and C 20.0, M 17.3, Y 16.7 and K 21.9 points at 40%. +#[test] +fn generic_cmyk_tints_gain_like_tr001() { + let g = generic(); + let tvi = |ink: usize, a: f32| { + let ch = [0, 1, 2, 1][ink]; + let mut cmyk = [0.0f32; 4]; + cmyk[ink] = a; + let tint = g.to_xyz_rel(cmyk)[ch]; + cmyk[ink] = 1.0; + let solid = g.to_xyz_rel(cmyk)[ch]; + let paper = g.to_xyz_rel([0.0; 4])[ch]; + (paper - tint) / (paper - solid) - a + }; + for (ink, (at50, at40)) in [(0.200, 0.200), (0.176, 0.173), (0.168, 0.167), (0.216, 0.219)].into_iter().enumerate() { + assert!((tvi(ink, 0.5) - at50).abs() < 0.002, "ink {ink} at 50%: {}", tvi(ink, 0.5)); + assert!((tvi(ink, 0.4) - at40).abs() < 0.002, "ink {ink} at 40%: {}", tvi(ink, 0.4)); + } +} + +/// More of any ink never makes a Generic CMYK color lighter, in any XYZ channel: on a 10% grid of +/// builds within the 300% ink limit, adding 10% of one ink keeps X, Y and Z or lowers them. +#[test] +fn generic_cmyk_more_ink_is_never_lighter() { + let g = generic(); + let steps = (0..=10).map(|i| i as f32 / 10.0); + for c in steps.clone() { + for m in steps.clone() { + for y in steps.clone() { + for k in steps.clone() { + let base = [c, m, y, k]; + if base.iter().sum::() > 3.0 - 0.1 + 1e-4 { + continue; + } + let x0 = g.to_xyz_rel(base); + for ink in 0..4 { + if base[ink] >= 1.0 { + continue; + } + let mut more = base; + more[ink] += 0.1; + let x1 = g.to_xyz_rel(more); + assert!((0..3).all(|ch| x1[ch] <= x0[ch] + 1e-5), "{base:?} plus ink {ink}: {x0:?} to {x1:?}"); + } + } + } + } + } +} + +/// sRGB black separates to 90% black under neutral CMY that fills the 300% ink limit, light grays +/// carry no black, and black rises steadily from the light end of the gray ramp to the dark end. +#[test] +fn generic_cmyk_black_generation_follows_the_black_curve() { + let c = cms(); + let black = c.srgb_to_cmyk([0.0; 3], Intent::RelativeColorimetric); + assert!((black[3] - 0.9).abs() < 0.005, "{black:?}"); + assert!((black.iter().sum::() - 3.0).abs() < 0.01, "{black:?}"); + let lab = c.cmyk_to_lab(black); + assert!(lab.a.abs() < 0.05 && lab.b.abs() < 0.05, "neutral: {lab:?}"); + let light = c.srgb_to_cmyk([0.7; 3], Intent::RelativeColorimetric); + assert_eq!(light[3], 0.0, "{light:?}"); + let mut prev = 0.0; + for v in (0..=50).rev().map(|i| i as f32 / 50.0) { + let k = c.srgb_to_cmyk([v; 3], Intent::RelativeColorimetric)[3]; + assert!(k >= prev - 1e-4, "gray {v}: K {k} after {prev}"); + prev = k; + } +} + +/// A color whose solve holds an ink at a bound separates to the same inks when its values change by +/// a few float steps: hex f01482 (cyan held at 0) and hex ed1e79 (magenta held at 100%), each with +/// its red stepped one `f32` at a time over 4,001 values (about 0.00024 in all), stay within 0.001 +/// on every ink. #[test] fn generic_cmyk_separates_nearby_colors_at_the_gamut_edge_alike() { let c = cms(); - let pink = [237.0f32 / 255.0, 30.0 / 255.0, 121.0 / 255.0]; - let (mut lo, mut hi) = ([f32::MAX; 4], [f32::MIN; 4]); - for step in -2000i32..=2000 { - let mut rgb = pink; - rgb[0] = f32::from_bits(pink[0].to_bits().wrapping_add_signed(step)); - let ink = c.srgb_to_cmyk(rgb, Intent::RelativeColorimetric); - for i in 0..4 { - lo[i] = lo[i].min(ink[i]); - hi[i] = hi[i].max(ink[i]); + let spread = |pink: [f32; 3]| { + let (mut lo, mut hi) = ([f32::MAX; 4], [f32::MIN; 4]); + for step in -2000i32..=2000 { + let mut rgb = pink; + rgb[0] = f32::from_bits(pink[0].to_bits().wrapping_add_signed(step)); + let ink = c.srgb_to_cmyk(rgb, Intent::RelativeColorimetric); + for i in 0..4 { + lo[i] = lo[i].min(ink[i]); + hi[i] = hi[i].max(ink[i]); + } } - } + (lo, hi) + }; + let (lo, hi) = spread([240.0f32 / 255.0, 20.0 / 255.0, 130.0 / 255.0]); assert!((0..4).all(|i| hi[i] - lo[i] < 1e-3), "{lo:?} to {hi:?}"); assert_eq!(lo[0], 0.0, "{lo:?}"); + let (lo, hi) = spread([237.0f32 / 255.0, 30.0 / 255.0, 121.0 / 255.0]); + assert!((0..4).all(|i| hi[i] - lo[i] < 1e-3), "{lo:?} to {hi:?}"); + assert_eq!(hi[1], 1.0, "{hi:?}"); } /// A color whose solve holds cyan and magenta at 1 separates to the same inks when its values change diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index 09e8154c9..3ac145fd2 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -376,6 +376,11 @@ impl Appearance { pub fn has_placed_gradient(&self) -> bool { self.has_gradient(true) } + /// Does a fill or stroke carry a gradient that is drawn at a place in document space: a placed + /// one, or a freeform gradient with points of its own whose vector is fitted to the bounds? + pub fn has_positioned_gradient(&self) -> bool { + self.gradients().any(|g| g.is_placed() || g.has_freeform_points()) + } /// Fix unplaced gradients to their fit on `bounds` (the object's geometric bounds; strokes fit /// the stroke-inflated box), so they can follow transforms that refitting wouldn't reproduce. pub fn pin_gradients(&mut self, bounds: vectorcraft_geom::Rect) { diff --git a/crates/doc/src/assets.rs b/crates/doc/src/assets.rs index 824f3ebea..f17783b1e 100644 --- a/crates/doc/src/assets.rs +++ b/crates/doc/src/assets.rs @@ -34,7 +34,16 @@ impl Document { /// The name of the next unnamed asset: one more than the highest `Asset N` so far. pub fn next_asset_name(&self) -> String { - let n = self.assets.iter().filter_map(|a| a.name.strip_prefix(ASSET)?.parse::().ok()).max().unwrap_or(0); + let n = self + .assets + .iter() + .filter_map(|a| { + let digits = a.name.strip_prefix(ASSET)?; + // `parse` alone would accept a leading `+`. + digits.bytes().all(|b| b.is_ascii_digit()).then(|| digits.parse::().ok()).flatten() + }) + .max() + .unwrap_or(0); format!("{ASSET}{}", n.saturating_add(1)) } @@ -61,3 +70,23 @@ impl Document { v.into_iter().map(|(_, id)| id).collect() } } + +#[cfg(test)] +mod tests { + use super::*; + + fn name_after(names: &[&str]) -> String { + let mut doc = Document::new(100.0, 100.0); + for (i, n) in names.iter().enumerate() { + doc.assets.push(ExportAsset { id: i as u64 + 1, name: (*n).to_string(), nodes: vec![], format_3d: None }); + } + doc.next_asset_name() + } + + #[test] + fn next_asset_name_ignores_non_digit_suffixes() { + assert_eq!(name_after(&["Asset +5"]), "Asset 1"); + assert_eq!(name_after(&["Asset ", "Asset 5x"]), "Asset 1"); + assert_eq!(name_after(&["Asset 5"]), "Asset 6"); + } +} diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index bc44b7bf4..d25be2c41 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -19,7 +19,7 @@ use std::f64::consts::PI; use std::sync::Arc; use vectorcraft_color::{Color, Gradient, GradientPaint, GradientStop, Paint}; -use vectorcraft_geom::kurbo::ParamCurveArclen; +use vectorcraft_geom::kurbo::{ParamCurve, ParamCurveArclen, ParamCurveDeriv}; use vectorcraft_geom::{Affine, Anchor, BezPath, FillRule, PathData, Point, Rect, SubPath, Vec2}; use crate::appearance::{Appearance, AppearanceItem, Dash, StrokeLayer, WidthProfile}; @@ -904,9 +904,12 @@ pub struct Spine { cum: Vec, anchors: Vec, closed: bool, + path: PathData, } impl Spine { + /// Build a spine from the first subpath of `path`: flatten it to a polyline, record cumulative + /// arc lengths, and the arc length at each anchor. pub fn new(path: &PathData) -> Option { let sp = path.subpaths.first()?; let mut pts: Vec = Vec::new(); @@ -943,8 +946,9 @@ impl Spine { if pts.len() < 2 { return None; } - Some(Self { pts, cum, anchors, closed: sp.closed }) + Some(Self { pts, cum, anchors, closed: sp.closed, path: path.clone() }) } + /// The spine's flattened length in arc length. pub fn length(&self) -> f64 { *self.cum.last().unwrap_or(&0.0) } @@ -1002,20 +1006,32 @@ impl Spine { pub fn at(&self, f: f64) -> (Point, f64) { self.at_length(f.clamp(0.0, 1.0) * self.length()) } - /// Point and tangent angle (radians) at arc length `s` (clamped to the spine). + /// Point and tangent angle (radians) at arc length `s` (clamped to the spine). The point and + /// tangent come from the original curve at that arc length, not from the flattened polyline: a + /// flattened chord's direction is piecewise constant, so steps oriented to a curved spine with + /// `Align to Path` jump between chords instead of following the curve smoothly. pub fn at_length(&self, s: f64) -> (Point, f64) { - let last = self.pts.len().saturating_sub(2); let s = if s.is_finite() { s.clamp(0.0, self.length()) } else { 0.0 }; - let i = match self.cum.binary_search_by(|c| c.total_cmp(&s)) { - Ok(i) => i.min(last), - Err(i) => i.saturating_sub(1).min(last), - }; - let (Some(p0), Some(p1), Some(c0), Some(c1)) = (self.pts.get(i), self.pts.get(i + 1), self.cum.get(i), self.cum.get(i + 1)) else { - return (self.pts.first().copied().unwrap_or_default(), 0.0); - }; - let u = ((s - c0) / (c1 - c0).max(1e-12)).clamp(0.0, 1.0); - let d = *p1 - *p0; - (p0.lerp(*p1, u), d.y.atan2(d.x)) + let Some(sp) = self.path.subpaths.first() else { return (self.pts.first().copied().unwrap_or_default(), 0.0) }; + let n = sp.segment_count(); + let mut start = 0.0; + for i in 0..n { + let c = sp.segment(i); + let end = self.anchor_length(i + 1).unwrap_or(self.length()); + if s <= end || i + 1 == n { + let local_s = (s - start).max(0.0) / (end - start).max(1e-12) * c.arclen(1e-9); + let t = c.inv_arclen(local_s, 1e-9).clamp(0.0, 1.0); + let e = c.eval(t); + let tv = c.deriv().eval(t).to_vec2(); + // Where a handle sits on its anchor the derivative vanishes there: take the + // direction between points a little before and after instead. + let tv = if tv.hypot2() < 1e-24 { c.eval((t + 1e-3).min(1.0)) - c.eval((t - 1e-3).max(0.0)) } else { tv }; + return (Point::new(e.x, e.y), tv.y.atan2(tv.x)); + } + start = end; + } + // Only a spine without segments gets here: the loop returns at the last one. + (self.pts.first().copied().unwrap_or_default(), 0.0) } } @@ -1446,6 +1462,45 @@ mod tests { assert_eq!(out[0].geometric_bounds().unwrap().center(), Point::new(5.0, 5.0), "keys stay put"); } + #[test] + fn orientations_follow_a_curved_spine_smoothly() { + // A curved spine: from (0, 0) with handles (+50, +50) / (-50, -50) to (100, 0) with handles + // (+50, -50) / (-50, +50) — the classic up-bow arc. + let spine = PathData::single(SubPath::new( + vec![Anchor::smooth(Point::new(0.0, 0.0), Point::new(50.0, 50.0)), Anchor::smooth(Point::new(100.0, 0.0), Point::new(50.0, -50.0))], + false, + )); + let sp = Spine::new(&spine).unwrap(); + // Many steps along the bow: each step's tangent comes from the curve at its arc length, + // not from a flattened chord — so the orientations must sweep the true angle, smoothly. + let len = sp.length(); + let mut prev: Option = None; + for i in 1..=200 { + let (pt, ang) = sp.at_length(len * i as f64 / 201.0); + if let Some(p) = prev { + let step = (ang - p).abs().min(2.0 * std::f64::consts::PI - (ang - p).abs()); + assert!(step < 0.1, "tangent step {} is too big at {} ({}, {})", step, i, pt.x, pt.y); + } + prev = Some(ang); + } + } + + /// Where the spine starts at an anchor whose handle sits on it, the first step still faces along + /// the curve (towards the next handle), not along a direction of zero length. + #[test] + fn a_handle_on_its_anchor_still_gives_the_curve_direction() { + let spine = PathData::single(SubPath::new( + vec![Anchor::corner(Point::new(0.0, 0.0)), Anchor::smooth(Point::new(100.0, 0.0), Point::new(150.0, -50.0))], + false, + )); + let sp = Spine::new(&spine).unwrap(); + let (p, angle) = sp.at_length(0.0); + assert!(p.x.abs() < 1e-6 && p.y.abs() < 1e-6, "{p:?}"); + assert!((angle - std::f64::consts::FRAC_PI_4).abs() < 0.05, "faces the in handle at (50, 50): {angle}"); + let (_, end) = sp.at_length(sp.length()); + assert!(end.is_finite(), "{end}"); + } + #[test] fn easing_keeps_the_ends_and_stays_monotone() { use crate::live::{BlendEase, BlendEaseType as K}; diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 287a6b2aa..ebd27fd87 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -34,6 +34,7 @@ impl Document { graphic_styles, char_styles, para_styles, + composite_fonts, text_threads, symbols, graph_designs, @@ -90,6 +91,7 @@ impl Document { && *graphic_styles == other.graphic_styles && *char_styles == other.char_styles && *para_styles == other.para_styles + && *composite_fonts == other.composite_fonts && *text_threads == other.text_threads && symbols.len() == other.symbols.len() && symbols.iter().zip(&other.symbols).all(|(a, b)| a.name == b.name && Arc::ptr_eq(&a.art, &b.art)) @@ -213,6 +215,7 @@ impl Document { graphic_styles: default_graphic_styles().into(), char_styles: Default::default(), para_styles: Default::default(), + composite_fonts: Default::default(), text_threads: vec![], symbols: vec![], graph_designs: vec![], diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 8a866256e..5ab3b8bdf 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -121,6 +121,7 @@ pub use text::{ RubySpan, ScriptMetrics, SingleWordJustify, TabAlign, TabStop, TextFlow, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, VerticalAlign, WrapShape, }; +pub use text::{CharClass, CompositeFont, CompositePart, CompositeParts}; pub use trace::TraceView; pub use variables::{DataSet, DataValue, Variable, VariableKind, Variables}; pub use vectorcraft_color as color; @@ -195,6 +196,9 @@ pub struct Document { /// Paragraph styles (besides the built-in [Normal Paragraph Style]). #[serde(default, skip_serializing_if = "skip::is_empty_vec")] pub para_styles: Shared>, + /// Type › Composite Fonts…: the composite fonts defined in the document, by name. + #[serde(default, skip_serializing_if = "skip::is_empty_vec")] + pub composite_fonts: Shared>, /// Threaded text: area-type frames one story flows through, in order. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub text_threads: Vec>, diff --git a/crates/doc/src/mem.rs b/crates/doc/src/mem.rs index a93830822..e431fc9d1 100644 --- a/crates/doc/src/mem.rs +++ b/crates/doc/src/mem.rs @@ -103,6 +103,11 @@ impl Document { let styles = |s: &Vec| vec_bytes(s) + s.iter().map(|s| s.name.capacity() + map_bytes(&s.attrs)).sum::(); n += shared(&self.char_styles, &newer.char_styles, styles); n += shared(&self.para_styles, &newer.para_styles, styles); + n += shared(&self.composite_fonts, &newer.composite_fonts, |c| { + let part = |p: &crate::CompositePart| p.font.capacity() + p.style.capacity(); + vec_bytes(c) + + c.iter().map(|f| f.name.capacity() + crate::CharClass::ALL.into_iter().map(|k| part(f.parts.get(k))).sum::()).sum::() + }); n += shared(&self.saved_selections, &newer.saved_selections, |s| { vec_bytes(s) + s.iter().map(|s: &SavedSelection| s.name.capacity() + vec_bytes(&s.objects)).sum::() }); diff --git a/crates/doc/src/objects_on_path.rs b/crates/doc/src/objects_on_path.rs index 67c9c0ee8..cace68ce6 100644 --- a/crates/doc/src/objects_on_path.rs +++ b/crates/doc/src/objects_on_path.rs @@ -572,7 +572,8 @@ mod tests { place(&mut g, &symbols); g.transform(Affine::new([2.0, 0.0, 0.0, 1.0, 0.0, 0.0]), false); let rec = g.extras().objects_on_path.as_deref().cloned().unwrap(); - assert_eq!((rec.spacing, rec.start), (Some(40.0), 20.0)); + // The spine measures arc length along the curve, so within rounding of the exact values. + assert!(rec.spacing.is_some_and(|d| (d - 40.0).abs() < 1e-9) && (rec.start - 20.0).abs() < 1e-9, "{rec:?}"); place(&mut g, &symbols); let x = |id| g.children().and_then(|c| c.iter().find(|c| c.id == id).cloned()).unwrap().geometric_bounds().unwrap().center().x; assert!((x(NodeId(2)) - 20.0).abs() < 1e-9 && (x(NodeId(3)) - 60.0).abs() < 1e-9, "{} {}", x(NodeId(2)), x(NodeId(3))); diff --git a/crates/doc/src/setup.rs b/crates/doc/src/setup.rs index 99eee0336..781bec84e 100644 --- a/crates/doc/src/setup.rs +++ b/crates/doc/src/setup.rs @@ -120,6 +120,21 @@ impl Quotes { prev.is_none_or(|p| p.is_whitespace() || matches!(p, '(' | '[' | '{' | '—' | '–') || quote_opened.unwrap_or_else(|| self.is_open(p))) } + /// The straight quote a typed curly quote stands for: `"` for `”` (U+201D) and nothing else. + /// The macOS Serbian Cyrillic layout has no straight `"` and types `”` on Shift+2, so with + /// Typographer's Quotes on, a `”` typed alone where a quote opens takes the document's opening + /// quote ([`Quotes::opens_after`]); where a quote closes it stays `”`. `“ „` and the single + /// quotes `‘ ’ ‚` stay as typed: that layout's apostrophe key already types `’`, as Serbian + /// wants, and `’90s` keeps its apostrophe. + pub fn typed_as_straight(c: char) -> Option { + (c == '”').then_some('"') + } + + /// Does a quote typed after `prev` (the character before it, `None` at the start) open? + pub fn opens_after(&self, prev: Option) -> bool { + self.opens(prev, None) + } + /// `text` with straight quotes (`"` and `'`) replaced by these quotes: opening or closing by /// context (an apostrophe is a closing single quote). `prev` is the character before `text`, /// and is updated to its last character, so text can be converted in pieces. @@ -420,6 +435,12 @@ mod tests { } let mut prev = None; assert_eq!(language_quotes("croatian").unwrap().apply("\"a 'b' c\"", &mut prev), "„a ‚b‘ c“"); + // Only a typed `”` stands for a straight quote; the other curly quotes stay as typed. + assert_eq!(Quotes::typed_as_straight('”'), Some('"')); + assert!(['“', '„', '‘', '’', '‚', '"', '\'', '«', '»', '「', 'a'].iter().all(|&c| Quotes::typed_as_straight(c).is_none())); + // A quote opens at the start and after a space, and closes after a letter. + assert!(Quotes::ENGLISH.opens_after(None) && Quotes::ENGLISH.opens_after(Some(' '))); + assert!(!Quotes::ENGLISH.opens_after(Some('a'))); // Bulgarian: „…“ at either level, as CLDR has it. let mut prev = None; assert_eq!(language_quotes("bulgarian").unwrap().apply("\"a 'b' c\"", &mut prev), "„a „b“ c“"); diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index a2f21e856..9f900629d 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -8,6 +8,7 @@ use crate::appearance::{Appearance, AppearanceItem, Dash, FillLayer, LineCap, Li mod area; mod char_style; +mod composite; mod object; mod para_style; mod runs; @@ -17,12 +18,19 @@ mod wrap; pub use area::{AreaFit, AreaOptions, PathAlign, PathEffect, TextFlow, TextWrap, VerticalAlign, WrapShape}; use char_style::yes; pub use char_style::{CharAlign, CharPosition, CharStyle, Digits, FontHeightReference, GlyphChoice, ScriptMetrics}; +pub use composite::{ + COMPOSITE_BASELINE, COMPOSITE_FONTS_MAX, COMPOSITE_NAME_MAX, COMPOSITE_SCALE, COMPOSITE_SIZE, CharClass, CompositeFont, CompositePart, + CompositeParts, +}; pub use para_style::{ Burasagari, Composer, FirstBaseline, HyphenationOptions, JustificationOptions, Justify, Kashida, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, SingleWordJustify, }; pub use runs::{INLINE_CHAR, INLINE_MAX_SCALE, InlineArt, RubyAlign, RubyPosition, RubySpan, TextKind, TextRun, TextStyleDef}; -pub use tabs_lists::{Bullet, DEFAULT_TAB_INTERVAL, LIST_HANGING_INDENT, NumberStyle, ParaList, TabAlign, TabStop}; +pub use tabs_lists::{ + Bullet, DEFAULT_TAB_INTERVAL, LIST_HANGING_INDENT, LIST_LEVEL_INDENT, LIST_LEVELS, ListAlign, ListCounts, NumberStyle, ParaList, TabAlign, + TabStop, +}; pub use wrap::{resolve_wrap, wrap_work}; /// A text object. `runs` split into paragraphs at `\n`. @@ -96,6 +104,11 @@ pub struct TextObject { /// file saves the count only when it is not 0. #[serde(default, rename = "listCount", skip_serializing_if = "crate::skip::is_default")] pub list_count: u32, + /// Threaded text: as [`Self::list_count`], the counts the earlier frames carry into this frame + /// at list levels 2 on, level 2 first ([`ListCounts::from_parts`]). Empty when they are all 0, + /// and the file saves them only when not empty. A file's longer list opens with the first 8. + #[serde(default, rename = "listLevelCounts", skip_serializing_if = "Vec::is_empty", deserialize_with = "tabs_lists::read_level_counts")] + pub list_level_counts: Vec, /// Cached layout bounds in text space, filled in by the layout engine (not serialized). #[serde(skip)] pub cached_bounds: Option, diff --git a/crates/doc/src/text/area.rs b/crates/doc/src/text/area.rs index 515f52627..7bb742f48 100644 --- a/crates/doc/src/text/area.rs +++ b/crates/doc/src/text/area.rs @@ -101,15 +101,24 @@ impl PathEffect { } } -/// Type on a Path Options › Align to Path: which height of the type runs along the path. +/// Type on a Path Options › Align to Path: which height of the type runs along the path. Each +/// character is measured by its own font, size and vertical scale, and in vertical type by its own +/// size and horizontal scale. An inline graphic moves as a character of its run would, by its +/// run's vertical scale in vertical type too. These distances are VectorCraft's own values. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum PathAlign { - /// The font's top edge: the type hangs below the path. + /// The top of the font's em box: the type hangs below the path. The em box is the font's + /// ideographic em box (from its `BASE` table, its typographic or `hhea` box, or its + /// ideographs), else an em box centered on the cap height, else a box whose top is 0.88 em + /// above the baseline. In vertical type the characters move half an em. Ascender, - /// The font's bottom edge: the type stands above the path. + /// The bottom of the font's bounding box, else the font's descent: the type stands above the + /// path. In vertical type the characters move half an em the other way. Descender, - /// Halfway between the ascender and the descender. + /// A line at half the height of the top of the font's H above the baseline, else at half its + /// cap height, else at the middle of its em box. In vertical type the characters stay on the + /// path. Center, /// The baseline (the default). #[default] diff --git a/crates/doc/src/text/composite.rs b/crates/doc/src/text/composite.rs new file mode 100644 index 000000000..e95428c2f --- /dev/null +++ b/crates/doc/src/text/composite.rs @@ -0,0 +1,344 @@ +//! Type › Composite Fonts…: a font made of other fonts, one for each class of character (the +//! Kanji font is the base; kana, punctuation, symbols, Roman letters and numerals each take a font +//! of their own, sized, raised and scaled against it). Type set in a composite font names it as its +//! family; the layout sets each character in its class's font. +//! +//! Behavior reference (public documentation): +//! . +//! Which characters fall in which class, the ranges and the defaults are VectorCraft's own. + +use serde::{Deserialize, Serialize}; + +/// A class of characters a composite font gives a font of its own. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum CharClass { + /// The base font: ideographs, full-width letters and digits, and every character no other + /// class takes. + Kanji, + /// Hiragana and katakana, full-width and half-width. + Kana, + /// East Asian and full-width punctuation, and typographic quotes and dashes. + Punctuation, + /// Arrows, math, shapes, enclosed and other symbols. + Symbols, + /// Half-width Roman letters, their punctuation and spaces. + Roman, + /// The half-width digits 0 to 9. + Numerals, +} + +impl CharClass { + /// Every class, in the order the Composite Fonts dialog lists them. + pub const ALL: [CharClass; 6] = [Self::Kanji, Self::Kana, Self::Punctuation, Self::Symbols, Self::Roman, Self::Numerals]; + + /// The name commands and files use (`kanji`, `kana`, …). + pub fn key(self) -> &'static str { + match self { + Self::Kanji => "kanji", + Self::Kana => "kana", + Self::Punctuation => "punctuation", + Self::Symbols => "symbols", + Self::Roman => "roman", + Self::Numerals => "numerals", + } + } + + /// The class named `key` ([`Self::key`], any case). + pub fn named(key: &str) -> Option { + Self::ALL.into_iter().find(|c| c.key().eq_ignore_ascii_case(key)) + } + + /// Whether the class's characters can be scaled across and up (Kana, Roman and Numerals). + pub fn scales(self) -> bool { + matches!(self, Self::Kana | Self::Roman | Self::Numerals) + } + + /// The class of character `c`. + pub fn of(c: char) -> Self { + let u = c as u32; + match u { + 0x30..=0x39 => Self::Numerals, + // Basic Latin, Latin-1, Latin Extended, IPA, spacing and combining marks, and Latin + // Extended Additional. + 0x00..=0x036F | 0x1E00..=0x1EFF => Self::Roman, + // The other Latin blocks: combining marks extended, phonetic extensions and their + // supplement, combining marks supplement, Latin Extended-C, -D, -E, -F and -G, and the + // Latin ligatures of the alphabetic presentation forms. + 0x1AB0..=0x1AFF + | 0x1D00..=0x1DFF + | 0x2C60..=0x2C7F + | 0xA720..=0xA7FF + | 0xAB30..=0xAB6F + | 0xFB00..=0xFB06 + | 0x10780..=0x107BF + | 0x1DF00..=0x1DFFF => Self::Roman, + // ・ and ゠ sit in the katakana block but are punctuation. + 0x30A0 | 0x30FB => Self::Punctuation, + // Hiragana, katakana, katakana phonetic extensions, half-width katakana (with its + // prolonged sound and voicing marks) and the kana supplements. + 0x3041..=0x309F | 0x30A1..=0x30FF | 0x31F0..=0x31FF | 0xFF66..=0xFF9F | 0x1B000..=0x1B16F => Self::Kana, + // CJK symbols: 〄 〒 〓 〠 〶; ideographic space, 々 〆 〇 and the like stay with the base. + 0x3004 | 0x3012 | 0x3013 | 0x3020 | 0x3036 => Self::Symbols, + 0x3000 | 0x3005..=0x3007 | 0x3021..=0x302F | 0x3031..=0x3035 | 0x3038..=0x303C => Self::Kanji, + 0x3001..=0x303F => Self::Punctuation, + // ※ is a symbol; the rest of General Punctuation (dashes, quotes, leaders) punctuation. + 0x203B => Self::Symbols, + 0x2010..=0x205E => Self::Punctuation, + // Letterlike symbols, number forms, arrows, math, technical, enclosed alphanumerics, box + // drawing, shapes, miscellaneous symbols and dingbats; enclosed CJK and CJK + // compatibility; full-width signs. + 0x2100..=0x2BFF | 0x3200..=0x33FF | 0xFFE0..=0xFFEE => Self::Symbols, + // Full-width forms of the ASCII signs. + 0xFF03..=0xFF06 | 0xFF0A | 0xFF0B | 0xFF1C..=0xFF1E | 0xFF20 | 0xFF3C | 0xFF3E | 0xFF5C | 0xFF5E => Self::Symbols, + // Full-width forms of the other ASCII punctuation, and the half-width CJK punctuation. + 0xFF01..=0xFF0F | 0xFF1A..=0xFF1F | 0xFF3B..=0xFF40 | 0xFF5B..=0xFF65 => Self::Punctuation, + _ => Self::Kanji, + } + } +} + +/// The font a composite font sets one class of characters in, and how. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CompositePart { + /// The font family and style. + pub font: String, + #[serde(default = "regular")] + pub style: String, + /// The size, in percent of the type's size. + #[serde(default = "hundred")] + pub size: f64, + /// How far the characters are raised from the baseline, in percent of the type's size + /// (negative lowers them). + #[serde(default)] + pub baseline: f64, + /// Horizontal and vertical scale, in percent (Kana, Roman and Numerals only). + #[serde(default = "hundred")] + pub h_scale: f64, + #[serde(default = "hundred")] + pub v_scale: f64, + /// Kana only: the characters are scaled from the center of the em box rather than from the + /// baseline. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub scale_at_center: bool, +} + +fn hundred() -> f64 { + 100.0 +} + +fn regular() -> String { + "Regular".into() +} + +impl CompositePart { + /// The part a new composite font gives every class: `font` in `style` at full size, on the + /// baseline, unscaled. + pub fn new(font: impl Into, style: impl Into) -> Self { + Self { font: font.into(), style: style.into(), size: 100.0, baseline: 0.0, h_scale: 100.0, v_scale: 100.0, scale_at_center: false } + } +} + +/// The six parts of a composite font, one per [`CharClass`]. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct CompositeParts { + pub kanji: CompositePart, + pub kana: CompositePart, + pub punctuation: CompositePart, + pub symbols: CompositePart, + pub roman: CompositePart, + pub numerals: CompositePart, +} + +impl CompositeParts { + /// Every class set in `part`. + pub fn all(part: CompositePart) -> Self { + Self { kanji: part.clone(), kana: part.clone(), punctuation: part.clone(), symbols: part.clone(), roman: part.clone(), numerals: part } + } + + pub fn get(&self, class: CharClass) -> &CompositePart { + match class { + CharClass::Kanji => &self.kanji, + CharClass::Kana => &self.kana, + CharClass::Punctuation => &self.punctuation, + CharClass::Symbols => &self.symbols, + CharClass::Roman => &self.roman, + CharClass::Numerals => &self.numerals, + } + } + + pub fn get_mut(&mut self, class: CharClass) -> &mut CompositePart { + match class { + CharClass::Kanji => &mut self.kanji, + CharClass::Kana => &mut self.kana, + CharClass::Punctuation => &mut self.punctuation, + CharClass::Symbols => &mut self.symbols, + CharClass::Roman => &mut self.roman, + CharClass::Numerals => &mut self.numerals, + } + } +} + +/// A composite font (Type › Composite Fonts…), saved with the document. Its name is the family +/// that type set in it names. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct CompositeFont { + pub name: String, + pub parts: CompositeParts, +} + +/// The size a part can take, in percent of the type's size. +pub const COMPOSITE_SIZE: std::ops::RangeInclusive = 10.0..=400.0; +/// How far a part can raise or lower its characters, in percent of the type's size. +pub const COMPOSITE_BASELINE: std::ops::RangeInclusive = -100.0..=100.0; +/// The horizontal and vertical scale a part can take, in percent. +pub const COMPOSITE_SCALE: std::ops::RangeInclusive = 10.0..=400.0; +/// The longest composite font name, in characters. +pub const COMPOSITE_NAME_MAX: usize = 255; +/// The most composite fonts a document or a file keeps. +pub const COMPOSITE_FONTS_MAX: usize = 256; + +impl CompositeFont { + /// A composite font named `name` setting every class in `font` and `style`. + pub fn new(name: impl Into, font: impl Into, style: impl Into) -> Self { + Self { name: name.into(), parts: CompositeParts::all(CompositePart::new(font, style)) } + } + + /// Do `a` and `b` name the same composite font: equal in any case? + pub fn same_name(a: &str, b: &str) -> bool { + a == b || a.to_lowercase() == b.to_lowercase() + } + + /// The font of `fonts` (a document's composite fonts) named `name`, in any case. + pub fn named<'a>(fonts: &'a [CompositeFont], name: &str) -> Option<&'a CompositeFont> { + fonts.iter().find(|f| Self::same_name(&f.name, name)) + } + + /// `name` trimmed, when it can name a composite font: not empty, at most + /// [`COMPOSITE_NAME_MAX`] characters and without control characters. + pub fn clean_name(name: &str) -> Option { + let name = name.trim(); + let ok = !name.is_empty() && name.chars().count() <= COMPOSITE_NAME_MAX && !name.chars().any(char::is_control); + ok.then(|| name.to_string()) + } + + /// Why this composite font can't be used, if it can't: a name [`Self::clean_name`] refuses, a + /// part without a font, a value out of its range, or a scale or Scale at Center on a class + /// that has none. + pub fn check(&self) -> Result<(), String> { + if Self::clean_name(&self.name).as_deref() != Some(self.name.as_str()) { + return Err(format!("`{}` can't name a composite font", self.name)); + } + for class in CharClass::ALL { + let p = self.parts.get(class); + let k = class.key(); + if p.font.trim().is_empty() { + return Err(format!("{k}: give a font")); + } + let within = |v: f64, r: &std::ops::RangeInclusive| v.is_finite() && r.contains(&v); + if !within(p.size, &COMPOSITE_SIZE) { + return Err(format!("{k}: size must be {} to {}%", COMPOSITE_SIZE.start(), COMPOSITE_SIZE.end())); + } + if !within(p.baseline, &COMPOSITE_BASELINE) { + return Err(format!("{k}: baseline must be {} to {}%", COMPOSITE_BASELINE.start(), COMPOSITE_BASELINE.end())); + } + for v in [p.h_scale, p.v_scale] { + if !within(v, &COMPOSITE_SCALE) { + return Err(format!("{k}: scale must be {} to {}%", COMPOSITE_SCALE.start(), COMPOSITE_SCALE.end())); + } + if v != 100.0 && !class.scales() { + return Err(format!("{k}: only kana, roman and numerals can be scaled")); + } + } + if p.scale_at_center && class != CharClass::Kana { + return Err(format!("{k}: only kana can be scaled at the center")); + } + } + Ok(()) + } + + /// `fonts` without the ones that can't be used: those [`Self::check`] refuses, a later font + /// sharing an earlier one's name (in any case), a part set in one of the fonts, and those past + /// [`COMPOSITE_FONTS_MAX`]. `None` when every font can be used. + pub fn usable(fonts: &[CompositeFont]) -> Option> { + let mut names = std::collections::HashSet::new(); + let mut kept: Vec = + fonts.iter().filter(|f| f.check().is_ok() && names.insert(f.name.to_lowercase())).take(COMPOSITE_FONTS_MAX).cloned().collect(); + kept.retain(|f| CharClass::ALL.iter().all(|&c| !names.contains(&f.parts.get(c).font.to_lowercase()))); + (kept.len() != fonts.len()).then_some(kept) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Each class takes its characters: digits and Latin letters are half-width numerals and + /// Roman, kana both widths, and the punctuation, symbols and full-width forms their own. + #[test] + fn characters_fall_in_their_classes() { + for (s, class) in [ + ("漢字国一龍〇々", CharClass::Kanji), + ("AZa09 ", CharClass::Kanji), + ("あをアンーアンー", CharClass::Kana), + ("、。「」『』【】〜・!?():", CharClass::Punctuation), + ("“”‘’—…", CharClass::Punctuation), + ("※→★○〒+=$%①㈱", CharClass::Symbols), + ("AZaz é!? ()", CharClass::Roman), + // Latin Extended-C, -D and -E, phonetic extensions and a ligature. + ("\u{2C60}\u{A7A0}\u{AB30}\u{1D00}\u{FB01}", CharClass::Roman), + ("0123456789", CharClass::Numerals), + ] { + for c in s.chars() { + assert_eq!(CharClass::of(c), class, "{c:?} (U+{:04X})", c as u32); + } + } + } + + /// A composite font is read and written by its fields' names, and a part's omitted values + /// take the defaults. + #[test] + fn composite_fonts_read_back_with_defaults() { + let mut f = CompositeFont::new("Mixed", "Source Sans 3", "Regular"); + f.parts.kana.v_scale = 90.0; + f.parts.kana.scale_at_center = true; + f.parts.roman.baseline = -5.0; + let json = serde_json::to_value(&f).unwrap(); + assert_eq!(json["parts"]["kana"]["vScale"], 90.0); + assert_eq!(json["parts"]["kana"]["scaleAtCenter"], true); + assert!(json["parts"]["roman"].get("scaleAtCenter").is_none()); + assert_eq!(serde_json::from_value::(json).unwrap(), f); + let part: CompositePart = serde_json::from_value(serde_json::json!({"font": "Inter"})).unwrap(); + assert_eq!(part, CompositePart::new("Inter", "Regular")); + } + + /// Names are trimmed; values out of their ranges, and scales on classes that don't scale, are + /// refused. + #[test] + fn composite_fonts_check_their_values() { + assert_eq!(CompositeFont::clean_name(" Mixed ").as_deref(), Some("Mixed")); + for bad in ["", " ", "a\tb", &"x".repeat(COMPOSITE_NAME_MAX + 1)] { + assert_eq!(CompositeFont::clean_name(bad), None, "{bad:?}"); + } + let ok = CompositeFont::new("Mixed", "Inter", "Regular"); + assert_eq!(ok.check(), Ok(())); + let with = |f: fn(&mut CompositeParts)| { + let mut c = ok.clone(); + f(&mut c.parts); + c.check() + }; + assert!(with(|p| p.roman.size = 9.0).is_err()); + assert!(with(|p| p.roman.size = f64::NAN).is_err()); + assert!(with(|p| p.kana.baseline = 101.0).is_err()); + assert!(with(|p| p.kana.h_scale = 401.0).is_err()); + assert!(with(|p| p.kanji.v_scale = 90.0).is_err()); + assert!(with(|p| p.symbols.h_scale = 90.0).is_err()); + assert!(with(|p| p.roman.scale_at_center = true).is_err()); + assert!(with(|p| p.numerals.font = " ".into()).is_err()); + assert_eq!(with(|p| p.kana.scale_at_center = true), Ok(())); + assert_eq!(with(|p| p.numerals.h_scale = 50.0), Ok(())); + let mut spaced = ok.clone(); + spaced.name = " Mixed".into(); + assert!(spaced.check().is_err()); + } +} diff --git a/crates/doc/src/text/object.rs b/crates/doc/src/text/object.rs index 70e953922..7d226aa3e 100644 --- a/crates/doc/src/text/object.rs +++ b/crates/doc/src/text/object.rs @@ -2,7 +2,7 @@ use super::area::{AreaOptions, PathAlign, PathEffect}; use super::char_style::CharStyle; use super::para_style::ParaStyle; use super::runs::{RubySpan, TextKind, TextRun, move_anchors, reverse_from_end}; -use super::tabs_lists::ParaList; +use super::tabs_lists::{ListCounts, ParaList}; use super::{Affine, PathData, Point, Rect, TextObject, TextStyleScale, Vec2}; impl TextObject { @@ -93,10 +93,20 @@ impl TextObject { system_layout: false, fractional_widths: true, list_count: 0, + list_level_counts: Vec::new(), cached_bounds: None, cached_baselines: Vec::new(), } } + /// The list counts the story's earlier frames carry into this frame at each level + /// ([`Self::list_count`] and [`Self::list_level_counts`]). + pub fn list_counts(&self) -> ListCounts { + ListCounts::from_parts(self.list_count, &self.list_level_counts) + } + /// Set the list counts the story's earlier frames carry into this frame ([`Self::list_counts`]). + pub fn set_list_counts(&mut self, counts: ListCounts) { + (self.list_count, self.list_level_counts) = counts.parts(); + } pub fn plain_text(&self) -> String { self.runs.iter().map(|r| r.text.as_str()).collect() } diff --git a/crates/doc/src/text/para_style.rs b/crates/doc/src/text/para_style.rs index f189801bc..0300e604d 100644 --- a/crates/doc/src/text/para_style.rs +++ b/crates/doc/src/text/para_style.rs @@ -258,6 +258,12 @@ impl ParaStyle { } } + /// The left indent the layout sets the paragraph at: its left indent, and for a paragraph in a + /// list at a level past the first, [`ParaList::level_indent`] more. + pub fn laid_out_left_indent(&self) -> f64 { + self.left_indent + self.list.map_or(0.0, |l| l.level_indent()) + } + /// The leading of characters styled `st` in this paragraph, as the layout sets it: their own /// leading, or for Auto leading the paragraph's Justification › Auto Leading share of their /// size (exactly [`CharStyle::effective_leading`] at the default 120%, or when the share is diff --git a/crates/doc/src/text/tabs_lists.rs b/crates/doc/src/text/tabs_lists.rs index 7f6382b2f..c4ec92b70 100644 --- a/crates/doc/src/text/tabs_lists.rs +++ b/crates/doc/src/text/tabs_lists.rs @@ -155,14 +155,29 @@ fn roman(mut n: u32) -> String { s } +/// Where a list's marker stands before the first line's text ([`ParaList::align`]): the Bullets +/// and Numbering dialog's Alignment. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ListAlign { + /// The marker starts where the first line starts: at the left indent plus the first-line + /// indent. + #[default] + Left, + /// The marker ends where the first line's text starts, against the text. + Right, +} + /// The list a paragraph is in (Paragraph panel › Bullets and Numbered List). The layout draws the /// list's marker before the paragraph's first line, in the font, size and color of the paragraph's /// first character without its baseline shift, rotation, superscript or subscript, tracking, manual /// kerning, tate-chu-yoko, underline and strikethrough ([`Self::marker_style`]), and the text after -/// it starts at the next tab stop, the left indent counting as a stop. The marker is not a -/// character of the text, and an empty paragraph has none. A saved list reads the fields it leaves -/// out as their defaults, and a build ignores the fields it doesn't know. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +/// it starts at the next tab stop, the left indent counting as a stop. The marker stands at the +/// first line's start, or against the text with [`ListAlign::Right`]. A paragraph at a level past +/// the first ([`Self::level`]) is set [`LIST_LEVEL_INDENT`] further in for each level. The marker +/// is not a character of the text, and an empty paragraph has none. A saved list reads the fields +/// it leaves out as their defaults, and a build ignores the fields it doesn't know. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(default)] pub struct ParaList { /// The bullet each paragraph of a bulleted list starts with. A numbered list keeps it for @@ -172,17 +187,127 @@ pub struct ParaList { /// numbered list. #[serde(skip_serializing_if = "Option::is_none")] pub number: Option, + /// Where the marker stands before the first line's text. The file saves it only when it is + /// [`ListAlign::Right`]. + #[serde(skip_serializing_if = "crate::skip::is_default")] + pub align: ListAlign, + /// The paragraph's level in the list, 1 to [`LIST_LEVELS`] (Tab and Shift+Tab at the start of + /// the paragraph, Bullets and Numbering…'s Level): each level counts its own numbers + /// ([`Self::next_marker`]). A value outside that range is read as the nearest level + /// ([`Self::depth`]). The file saves it only when it is not 1. + #[serde(skip_serializing_if = "is_first_level", deserialize_with = "read_level")] + pub level: u8, +} + +impl Default for ParaList { + /// A bulleted list with a dot, its marker aligned left, at level 1. + fn default() -> Self { + Self { bullet: Bullet::default(), number: None, align: ListAlign::default(), level: 1 } + } +} + +/// Is `level` the first list level, which the file does not save? +fn is_first_level(level: &u8) -> bool { + *level == 1 +} + +/// Reads a saved list level, a whole number, as the nearest of 1 to [`LIST_LEVELS`], so a level +/// such as -1 or 300 opens the file at level 1 or 9. Anything else is an error. +fn read_level<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + let level = match serde_json::Value::deserialize(d)? { + serde_json::Value::Number(n) if n.is_i64() || n.is_u64() => n.as_i64().map_or(LIST_LEVELS as i64, |n| n.clamp(1, LIST_LEVELS as i64)), + _ => return Err(serde::de::Error::custom("a list level must be a whole number")), + }; + Ok(level as u8) +} + +/// Reads a type object's saved counts at list levels 2 on ([`ListCounts::from_parts`]), keeping +/// the first `LIST_LEVELS - 1` and skipping the rest of a longer list without storing it. +pub(super) fn read_level_counts<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + struct Counts; + impl<'de> serde::de::Visitor<'de> for Counts { + type Value = Vec; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("a list of list counts") + } + fn visit_seq>(self, mut seq: A) -> Result, A::Error> { + let mut out = Vec::new(); + while out.len() < LIST_LEVELS - 1 { + match seq.next_element::()? { + Some(c) => out.push(c), + None => return Ok(out), + } + } + while seq.next_element::()?.is_some() {} + Ok(out) + } + } + d.deserialize_seq(Counts) +} + +/// How many levels a list has (Bullets and Numbering…'s Level: 1 to 9). +pub const LIST_LEVELS: usize = 9; + +/// How much further in (pt) the layout sets a list paragraph for each level past the first: its +/// left indent counts that much more, and the marker and the text move with it. +pub const LIST_LEVEL_INDENT: f64 = 18.0; + +/// The numbered paragraphs a story's list has counted at each level, level 1 first +/// ([`ParaList::next_marker`]). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct ListCounts(pub [u32; LIST_LEVELS]); + +impl ListCounts { + /// The counts with `first` at level 1 and `deeper` at levels 2 on, a missing level at 0, as a + /// type object saves them ([`TextObject::list_count`], [`TextObject::list_level_counts`]). + pub fn from_parts(first: u32, deeper: &[u32]) -> Self { + let mut counts = [0; LIST_LEVELS]; + counts[0] = first; + for (c, &d) in counts[1..].iter_mut().zip(deeper) { + *c = d; + } + Self(counts) + } + + /// The count at level 1, and the counts at levels 2 on without the zeros that end them. + pub fn parts(&self) -> (u32, Vec) { + let deeper = &self.0[1..]; + let used = deeper.iter().rposition(|&c| c != 0).map_or(0, |i| i + 1); + (self.0[0], deeper[..used].to_vec()) + } } impl ParaList { /// A bulleted list with `bullet`. pub fn bulleted(bullet: Bullet) -> Self { - Self { bullet, number: None } + Self { bullet, ..Self::default() } } /// A numbered list with `style`. pub fn numbered(style: NumberStyle) -> Self { - Self { bullet: Bullet::default(), number: Some(style) } + Self { number: Some(style), ..Self::default() } + } + + /// This list with its marker at `align`. + pub fn aligned(self, align: ListAlign) -> Self { + Self { align, ..self } + } + + /// This list at level `level` (1 to [`LIST_LEVELS`]). + pub fn at_level(self, level: u8) -> Self { + Self { level, ..self } + } + + /// The list's level, 1 to [`LIST_LEVELS`]: [`Self::level`], a value outside the range taken + /// as the nearest level. + pub fn depth(&self) -> usize { + usize::from(self.level).clamp(1, LIST_LEVELS) + } + + /// How much further in (pt) than its left indent the layout sets a paragraph of this list: + /// [`LIST_LEVEL_INDENT`] for each level past the first. + pub fn level_indent(&self) -> f64 { + (self.depth() - 1) as f64 * LIST_LEVEL_INDENT } /// The characters of the marker the layout draws before the paragraph: the bullet, or for a @@ -195,18 +320,26 @@ impl ParaList { } /// The marker of the next paragraph of a story, whose list is `list` (None: in no list) and - /// which holds no text when `empty`, with `count` the numbered paragraphs counted before it. - /// A paragraph in no list sets the count back to 0 and has no marker; an empty paragraph has - /// none and leaves the count as it is; a bulleted paragraph has its bullet and leaves the - /// count as it is; a numbered paragraph adds one to the count and is numbered with it. - pub fn next_marker(list: Option, empty: bool, count: &mut u32) -> Option { + /// which holds no text when `empty`, with `counts` the numbered paragraphs counted before it at + /// each level. A paragraph in no list sets every count back to 0 and has no marker; an empty + /// paragraph has none and leaves the counts as they are. A paragraph with text sets the counts + /// of the levels deeper than its own back to 0, so a deeper paragraph after it counts from 1 + /// again; a bulleted one has its bullet and leaves the count of its level as it is, and a + /// numbered one adds one to the count of its level and is numbered with it. A paragraph back + /// at a shallower level then goes on with that level's count. + pub fn next_marker(list: Option, empty: bool, counts: &mut ListCounts) -> Option { let Some(list) = list else { - *count = 0; + *counts = ListCounts::default(); return None; }; if empty { return None; } + let level = list.depth() - 1; + for deeper in &mut counts.0[level + 1..] { + *deeper = 0; + } + let count = &mut counts.0[level]; if list.number.is_some() { *count = count.saturating_add(1); } diff --git a/crates/doc/src/text/tests.rs b/crates/doc/src/text/tests.rs index a3f4ab4d2..6a997c9e5 100644 --- a/crates/doc/src/text/tests.rs +++ b/crates/doc/src/text/tests.rs @@ -194,13 +194,13 @@ fn number_styles_write_their_numbers_and_roll_over() { assert_eq!(ParaList::numbered(NumberStyle::UpperRoman).marker(4), "IV."); assert_eq!(ParaList::bulleted(Bullet::Square).marker(4), "\u{25AA}"); let (num, dot) = (Some(ParaList::numbered(NumberStyle::Arabic)), Some(ParaList::bulleted(Bullet::Dot))); - let mut count = 0; + let mut count = ListCounts::default(); let markers: Vec> = [(num, false), (num, true), (dot, false), (num, false), (None, false), (num, false)] .iter() .map(|&(l, empty)| ParaList::next_marker(l, empty, &mut count)) .collect(); assert_eq!(markers, [Some("1.".into()), None, Some("\u{2022}".into()), Some("2.".into()), None, Some("1.".into())]); - assert_eq!(count, 1); + assert_eq!(count.parts(), (1, vec![])); let read = |v: serde_json::Value| serde_json::from_value::(v).unwrap(); assert_eq!(read(serde_json::json!({"bullet": "dash"})), ParaList::bulleted(Bullet::Dash)); assert_eq!(read(serde_json::json!({"number": "threeDigits"})), ParaList::numbered(NumberStyle::ThreeDigits)); @@ -211,6 +211,20 @@ fn number_styles_write_their_numbers_and_roll_over() { ); } +/// A list's marker alignment: Left by default and not saved, Right saved as `"align": "right"`; +/// a list saved without it reads as Left. +#[test] +fn a_list_saves_its_marker_alignment_only_when_it_is_right() { + let right = ParaList::numbered(NumberStyle::Arabic).aligned(ListAlign::Right); + assert_eq!(ParaList::bulleted(Bullet::Dot).align, ListAlign::Left); + assert_eq!(serde_json::to_value(right).unwrap(), serde_json::json!({"bullet": "dot", "number": "arabic", "align": "right"})); + assert_eq!(serde_json::to_value(ParaList::bulleted(Bullet::Dash)).unwrap(), serde_json::json!({"bullet": "dash"})); + let read = |v: serde_json::Value| serde_json::from_value::(v).unwrap(); + assert_eq!(read(serde_json::json!({"bullet": "dot", "number": "arabic", "align": "right"})), right); + assert_eq!(read(serde_json::json!({"bullet": "square"})).align, ListAlign::Left); + assert!(serde_json::from_value::(serde_json::json!({"align": "center"})).is_err()); +} + /// Typed list detection: "1.", "1)", "A." and "A)" and a space at the start of a paragraph /// start a numbered list, and "*", "+", "#" and ">" and a space start a bulleted list with the /// filled dot, the hollow circle, the filled square and the dash. Other text, the same characters @@ -240,6 +254,70 @@ fn typed_list_starts_are_found_at_a_paragraphs_start() { assert_eq!(t.typed_list_at(19), Some((16, letters))); } +/// List levels: each level counts its own numbers, a deeper paragraph starts its count at 1, +/// a paragraph back at a shallower level goes on with that level's count, and a paragraph in +/// no list starts every level again. A level past the first sets the paragraph 18 pt further +/// in; the file saves the level only when it is not 1, and a level outside 1 to 9 reads as the +/// nearest one. A type object's counts save level 1 and the deeper levels apart. +#[test] +fn list_levels_count_their_own_numbers_and_save_only_past_the_first() { + let num = |level| Some(ParaList::numbered(NumberStyle::Arabic).at_level(level)); + let dot = |level| Some(ParaList::bulleted(Bullet::Dot).at_level(level)); + let mut counts = ListCounts::default(); + let markers: Vec> = [ + (num(1), false), + (num(2), false), + (num(2), false), + (num(3), false), + (num(2), true), + (num(2), false), + (num(1), false), + (num(2), false), + (dot(1), false), + (num(2), false), + (num(1), false), + (None, false), + (num(2), false), + ] + .iter() + .map(|&(l, empty)| ParaList::next_marker(l, empty, &mut counts)) + .collect(); + let want = ["1.", "1.", "2.", "1.", "", "3.", "2.", "1.", "\u{2022}", "1.", "3.", "", "1."]; + assert_eq!(markers.iter().map(|m| m.as_deref().unwrap_or("")).collect::>(), want); + assert_eq!(counts, ListCounts([0, 1, 0, 0, 0, 0, 0, 0, 0])); + assert_eq!((ParaList::default().level, ParaList::bulleted(Bullet::Dot).depth()), (1, 1)); + assert_eq!([0, 1, 4, 9, 12].map(|l| ParaList::bulleted(Bullet::Dot).at_level(l).depth()), [1, 1, 4, 9, 9]); + assert_eq!([1, 2, 9].map(|l| ParaList::bulleted(Bullet::Dot).at_level(l).level_indent()), [0.0, 18.0, 144.0]); + let para = ParaStyle { left_indent: 18.0, list: dot(3), ..Default::default() }; + assert_eq!((para.laid_out_left_indent(), ParaStyle { list: None, ..para.clone() }.laid_out_left_indent()), (54.0, 18.0)); + assert_eq!(serde_json::to_value(dot(1)).unwrap(), serde_json::json!({"bullet": "dot"})); + assert_eq!(serde_json::to_value(num(4)).unwrap(), serde_json::json!({"bullet": "dot", "number": "arabic", "level": 4})); + let read = |v: serde_json::Value| serde_json::from_value::(v).unwrap(); + assert_eq!((read(serde_json::json!({"bullet": "dash"})).level, read(serde_json::json!({"level": 4}))), (1, dot(4).unwrap())); + assert!(serde_json::from_value::(serde_json::json!({"level": "two"})).is_err()); + assert!(serde_json::from_value::(serde_json::json!({"level": 2.5})).is_err()); + let levels = [serde_json::json!(0), serde_json::json!(-1), serde_json::json!(12), serde_json::json!(300), serde_json::json!(u64::MAX)]; + assert_eq!( + levels.map(|l| read(serde_json::json!({ "level": l })).level), + [1, 1, 9, 9, 9], + "a saved level outside 1 to 9 reads as the nearest one" + ); + let deep = ListCounts([5, 0, 2, 0, 0, 0, 0, 0, 0]); + assert_eq!(deep.parts(), (5, vec![0, 2])); + assert_eq!((ListCounts::from_parts(5, &[0, 2]), ListCounts::default().parts()), (deep, (0, vec![]))); + let mut t = TextObject::point(Point::ZERO, "Text", CharStyle::default()); + t.set_list_counts(deep); + assert_eq!((t.list_count, t.list_level_counts.as_slice(), t.list_counts()), (5, &[0, 2][..], deep)); + let saved = serde_json::to_value(&t).unwrap(); + assert_eq!((&saved["listCount"], &saved["listLevelCounts"]), (&serde_json::json!(5), &serde_json::json!([0, 2]))); + t.set_list_counts(ListCounts::default()); + assert!(serde_json::to_value(&t).unwrap().get("listLevelCounts").is_none(), "no counts past level 1 are saved without them"); + let mut saved = serde_json::to_value(&t).unwrap(); + saved["listLevelCounts"] = serde_json::json!((1..=20).collect::>()); + let opened = serde_json::from_value::(saved).unwrap(); + assert_eq!(opened.list_level_counts, (1..=8).collect::>(), "a file keeps only the counts of levels 2 to 9"); +} + /// In a threaded frame whose first paragraph continues from the frame before, the text at the /// frame's start is not the start of a paragraph, so typing a list's start there starts nothing. /// The paragraphs after it still start lists. diff --git a/crates/effects/src/geometry.rs b/crates/effects/src/geometry.rs index 715143e1a..57a10f66e 100644 --- a/crates/effects/src/geometry.rs +++ b/crates/effects/src/geometry.rs @@ -236,10 +236,11 @@ impl Carry { self.0.is_empty() } - /// Does it move any of the gradients of `ap`? Callers check first, so an appearance shared - /// with other objects is not copied for nothing. + /// Does it move any of the gradients of `ap` (those drawn at a place in document space, a + /// freeform gradient's own points included)? Callers check first, so an appearance shared with + /// other objects is not copied for nothing. pub fn moves(&self, ap: &Appearance) -> bool { - !self.0.is_empty() && ap.has_placed_gradient() + !self.0.is_empty() && ap.has_positioned_gradient() } /// Move the placed gradients of `ap` along, through each step in turn. diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index e4ca42a4e..84c83d2e7 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -80,13 +80,13 @@ pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks}; pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace}; pub use raster::{RASTERIZE, RasterFx, Rasterize, color_param, outset, raster_effects}; pub use reshape::{ - SYMBOL_ART_MAX_NODES, expand_outlined, has_gradient_copies, has_item_geometry, has_transform_art, has_transform_copies, has_transform_options, - image_paint, needs_outline, outline_art, outline_text, reshape, + SYMBOL_ART_MAX_NODES, expand_outlined, gives_image_shape, has_gradient_copies, has_item_geometry, has_transform_art, has_transform_copies, + has_transform_options, image_paint, needs_outline, outline_art, outline_text, reshape, }; pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve}; pub use solids::{ EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SOLIDS, SolidMesh, THREE_D_TYPES, extrude_options, has_solid, inflate_options, is_three_d, solid_art, - three_d_meshes, three_d_params_as, validate_solid, validate_three_d, view_options, + three_d_meshes, three_d_meshes_of, three_d_params_as, validate_solid, validate_three_d, view_options, }; pub use stylize::{ScribbleSetting, scribble_setting_id, scribble_settings}; pub use svg_filter::SVG_FILTER; diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 4ea3e4610..7fabc0490 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -133,7 +133,13 @@ fn gives_shape(e: &Effect) -> bool { /// Does `item`, a fill or stroke of an image, paint something: does it paint and carry an effect /// that gives it a shape ([`gives_shape`])? fn shapes_image(item: &AppearanceItem) -> bool { - crate::paints(item) && item.effects().iter().any(gives_shape) + crate::paints(item) && gives_image_shape(item.effects()) +} + +/// Do `effects`, those of an image's fill or stroke, give that fill or stroke a shape to paint +/// ([`gives_shape`])? +pub fn gives_image_shape(effects: &[Effect]) -> bool { + effects.iter().any(gives_shape) } /// The fills and strokes of `items` (an image's own) that paint something ([`shapes_image`]), each @@ -670,8 +676,7 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b if follows { group_marks(art, &mut vec![], &HashSet::new(), &mut groups); } - // Whether a path kept its shape (past the budget), and the ids of those that did. - let mut kept = false; + // The ids of the paths that kept their shape (past the budget). let mut kept_ids = HashSet::new(); let transform = (e.id == "distort.transform").then(|| { let (m, copies) = crate::distort::transform_map(bounds, ¶ms, seed); @@ -697,7 +702,6 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b let (made, moved) = transform_leaf(leaf, &path, map, copies, options, room); used = used.minus(before).plus(made); if !moved { - kept = true; kept_ids.insert(leaf.id); } return; @@ -709,12 +713,11 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b } } let Some(out) = apply_one(&e.id, ¶ms, &path, bounds, &GeomContext { seed, ..GeomContext::of(leaf) }, room) else { - kept = true; kept_ids.insert(leaf.id); return; }; used = used.minus(before).plus(Budget::of(&out)); - if follows && !boxes && leaf.appearance.has_placed_gradient() { + if follows && !boxes && leaf.appearance.has_positioned_gradient() { crate::Carry::of(&e.id, ¶ms, bounds, None, None, seed).apply(&mut leaf.appearance); } set_geometry(leaf, out, rule); @@ -732,13 +735,13 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b } // What paints the art as a whole, and the own fills and strokes of the groups inside it, // follow their bounds under Free Distort (they stay when no path changed them). Each stays - // when a path under it kept its shape (past the budget). + // when a path under it that its bounds measure kept its shape (past the budget). + let mut after = vec![]; + let held = (!groups.is_empty() || !kept_ids.is_empty()) && group_marks(art, &mut vec![], &kept_ids, &mut after).1; if !groups.is_empty() { - let mut after = vec![]; - group_marks(art, &mut vec![], &kept_ids, &mut after); carry_groups(art, groups, after, &|from, to| crate::Carry::of(&e.id, ¶ms, bounds, from, to, seed)); } - if follows && !kept { + if follows && !held { whole.push(crate::Carry::of(&e.id, ¶ms, bounds, Some(bounds), art.geometric_bounds(), seed)); } } @@ -1020,7 +1023,7 @@ fn rebase_type_outlines(n: &mut Node, before: &mut std::vec::IntoIter bool { /// A group below the art that [`is_member_group`] takes and whose own fills or strokes hold a /// placed gradient: the child indices that lead to it from the art, its geometric bounds, and -/// whether a path below it kept its shape. +/// whether a path below it that its bounds measure kept its shape. type GroupMark = (Vec, Option, bool); /// The geometric bounds of `n` (as [`Node::geometric_bounds`] gives them) and whether a path or -/// compound path below it is in `kept`, measured in one walk. Each group below `n` that +/// compound path below it that those bounds measure is in `kept`, measured in one walk. Each group below `n` that /// [`GroupMark`] describes goes into `out`, reached from `n` through `at`. fn group_marks(n: &Node, at: &mut Vec, kept: &HashSet, out: &mut Vec) -> (Option, bool) { match &n.kind { @@ -1065,14 +1068,15 @@ fn group_marks(n: &Node, at: &mut Vec, kept: &HashSet, out: &mut for (i, c) in children.iter().enumerate() { at.push(i); let (b, h) = group_marks(c, at, kept, out); - if is_member_group(c) && c.appearance.has_placed_gradient() { + if is_member_group(c) && c.appearance.has_positioned_gradient() { out.push((at.clone(), b, h)); } at.pop(); - held |= h; + // Only members measured in the bounds hold the group's gradients in place. let counts = if *clip { i == 0 } else { i >= skip && (c.visible || !layer) }; if counts { bounds = vectorcraft_geom::union_opt(bounds, b); + held |= h; } } (bounds, held) @@ -1126,7 +1130,7 @@ fn rebase_leaves(n: &mut Node, before: &mut std::vec::IntoIter>) { if let Some(Some(from)) = before.next() && let Some(to) = n.geometric_bounds() && from != to - && n.appearance.has_placed_gradient() + && n.appearance.has_positioned_gradient() { follow_boxes(&mut n.appearance, from, to); } @@ -1224,7 +1228,7 @@ fn copy_placed(n: &mut Node, m: Affine, copies: usize, room: &mut usize) { _ => {} } // The placed gradients of its own fills and strokes move with it, as a path's do. - if xf != Affine::IDENTITY && one.appearance.has_placed_gradient() { + if xf != Affine::IDENTITY && one.appearance.has_positioned_gradient() { one.appearance.transform_gradients(xf); } one diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index ba4b0e657..946c6532b 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -228,8 +228,9 @@ pub(crate) fn expand_revolve_art(n: &Node) -> Option { /// The surfaces [`revolve_art`] draws, as meshes ([`crate::three_d_meshes`]): with the effect on /// the object, one in its top visible fill (else its stroke); with it on fills and strokes, one for -/// each of them. -pub(crate) fn revolve_meshes(n: &Node) -> Result, String> { +/// each of them. Each mesh adds its triangles to `total` as it is built, and the first that takes +/// `total` past `limit` stops with an error before the next is built. +pub(crate) fn revolve_meshes(n: &Node, total: &mut usize, limit: usize) -> Result, String> { let path = profile(n).ok_or("Revolve currently requires a path or compound path")?; let ctx = GeomContext::of(n); let mesh = |path: &PathData, fx: &Effect, item: &AppearanceItem| -> Result { @@ -250,7 +251,9 @@ pub(crate) fn revolve_meshes(n: &Node) -> Result, String> .find(|i| matches!(i, AppearanceItem::Fill(_)) && crate::paints(i)) .or_else(|| n.appearance.items.iter().rev().find(|i| crate::paints(i))) .ok_or("the revolved object has no fill or stroke")?; - return Ok(vec![mesh(&path, fx, item)?]); + let mut out = vec![]; + crate::solids::keep_mesh(&mut out, total, mesh(&path, fx, item)?, limit)?; + return Ok(out); } let path = geometry(&path, &n.appearance.effects, &ctx); let mut out = vec![]; @@ -259,7 +262,7 @@ pub(crate) fn revolve_meshes(n: &Node) -> Result, String> && let Some(fx) = item.effects().get(index) { let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(item)); - out.push(mesh(&p, fx, item)?); + crate::solids::keep_mesh(&mut out, total, mesh(&p, fx, item)?, limit)?; } } Ok(out) diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 09a2fc8ec..6f8eac9be 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -566,14 +566,15 @@ pub(crate) fn keep_mesh(out: &mut Vec, total: &mut usize, mesh: Solid /// The solids effect `fx` builds from `shapes`, as [`art_for`] draws them, as meshes: Rotate turns /// every fill and stroke; Extrude & Bevel and Inflate build one from each shape's top fill and one -/// from the outline of each stroke. A stroke whose solid is past the limits is left out. -fn meshes_for(fx: &Effect, shapes: &[Profile], total: &mut usize) -> Result, String> { +/// from the outline of each stroke. A stroke whose solid is past the limits is left out. Each mesh +/// is counted against `limit` ([`keep_mesh`]) before the next is built. +fn meshes_for(fx: &Effect, shapes: &[Profile], total: &mut usize, limit: usize) -> Result, String> { let label = crate::effect_info(&fx.id).map_or("This effect", |e| e.label.trim_end_matches('…')); let b = union_bounds(shapes).ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?; let p = crate::merged_params(&fx.id, &fx.params); let view = view_options(&p); let mut out = vec![]; - let mut keep = |out: &mut Vec, mesh: Mesh, color: [f32; 4]| keep_mesh(out, total, SolidMesh { mesh, color }, MAX_MESH_TRIANGLES); + let mut keep = |out: &mut Vec, mesh: Mesh, color: [f32; 4]| keep_mesh(out, total, SolidMesh { mesh, color }, limit); if fx.id == ROTATE { for s in shapes { for item in &s.items { @@ -585,8 +586,8 @@ fn meshes_for(fx: &Effect, shapes: &[Profile], total: &mut usize) -> Result = parts.iter().map(|(_, _, area, rule)| (area, *rule)).collect(); vectorcraft_three_d::inflate_meshes(&shapes, bounds, inflate_options(&p), view) @@ -614,16 +615,33 @@ fn meshes_for(fx: &Effect, shapes: &[Profile], total: &mut usize) -> Result Result, String> { - // Triangles of every solid together, against [`MAX_MESH_TRIANGLES`]. + meshes_within(n, &mut 0, MAX_MESH_TRIANGLES) +} + +/// The meshes of every object in `nodes` ([`three_d_meshes`]), in order, as one 3D asset exports +/// them: the objects share one budget of [`MAX_MESH_TRIANGLES`]. +pub fn three_d_meshes_of<'a>(nodes: impl IntoIterator) -> Result, String> { + meshes_of_within(nodes, MAX_MESH_TRIANGLES) +} + +/// [`three_d_meshes_of`] with a budget of `limit` triangles. +fn meshes_of_within<'a>(nodes: impl IntoIterator, limit: usize) -> Result, String> { let mut total = 0usize; + let mut out = vec![]; + for n in nodes { + out.extend(meshes_within(n, &mut total, limit)?); + } + Ok(out) +} + +/// [`three_d_meshes`], with the triangles of every solid added to `total` as it is built and an +/// error as soon as `total` passes `limit`. +fn meshes_within(n: &Node, total: &mut usize, limit: usize) -> Result, String> { let meshes = if crate::has_revolve(n) { - let mut out = vec![]; - for m in crate::revolve::revolve_meshes(n)? { - keep_mesh(&mut out, &mut total, m, MAX_MESH_TRIANGLES)?; - } - out + crate::revolve::revolve_meshes(n, total, limit)? } else if !has_solid(n) { return Err("the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate)".into()); } else if is_container(n) { @@ -631,21 +649,21 @@ pub fn three_d_meshes(n: &Node) -> Result, String> { let label = crate::effect_info(&fx.id).map_or("This effect", |e| e.label.trim_end_matches('…')); let mut shapes = members(n, label)?; reshape_members(n, n.appearance.effects.get(..index).unwrap_or_default(), &mut shapes); - meshes_for(fx, &shapes, &mut total)? + meshes_for(fx, &shapes, total, limit)? } else { let (path, rule) = profile(n).ok_or("3D export needs a path, compound path, type or group")?; let ctx = GeomContext::of(n); let paints = solid_paints(n); if let Some((index, fx)) = solid(&n.appearance.effects) { let path = geometry(&path, n.appearance.effects.get(..index).unwrap_or_default(), &ctx); - meshes_for(fx, &[Profile { path, rule, items: paints, opacity: 1.0 }], &mut total)? + meshes_for(fx, &[Profile { path, rule, items: paints, opacity: 1.0 }], total, limit)? } else { let path = geometry(&path, &n.appearance.effects, &ctx); let mut out = vec![]; for item in &paints { if let Some((index, fx)) = solid(item.effects()) { let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(item)); - out.extend(meshes_for(fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0 }], &mut total)?); + out.extend(meshes_for(fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0 }], total, limit)?); } } out diff --git a/crates/effects/src/solids/tests_mesh.rs b/crates/effects/src/solids/tests_mesh.rs index 9aca2ab8c..70706e068 100644 --- a/crates/effects/src/solids/tests_mesh.rs +++ b/crates/effects/src/solids/tests_mesh.rs @@ -54,9 +54,49 @@ fn the_meshes_of_one_object_share_one_triangle_budget() { let shapes = [Profile { path: profile(&n).unwrap().0, rule: FillRule::NonZero, items: solid_paints(&n), opacity: 1.0 }]; let Some(effect) = n.appearance.effects.first() else { panic!("no effect") }; let mut total = 0; - assert!(meshes_for(effect, &shapes, &mut total).is_ok()); + assert!(meshes_for(effect, &shapes, &mut total, MAX_MESH_TRIANGLES).is_ok()); let mut total = MAX_MESH_TRIANGLES - first + 1; - assert!(meshes_for(effect, &shapes, &mut total).is_err_and(|e| e.contains("too complex"))); + assert!(meshes_for(effect, &shapes, &mut total, MAX_MESH_TRIANGLES).is_err_and(|e| e.contains("too complex"))); +} + +#[test] +fn revolve_stops_at_the_first_mesh_past_the_budget() { + // The fill revolves into a mesh; the stroke's profile, copied 1000 times, is too complex to + // revolve at all. + let mut n = square_node(1, 100.0); + if let Some(AppearanceItem::Fill(f)) = n.appearance.items.get_mut(0) { + f.effects.push(fx(crate::REVOLVE, json!({}))); + } + let fill_only = n.clone(); + if let Some(AppearanceItem::Stroke(s)) = n.appearance.items.get_mut(1) { + s.effects.push(fx("distort.transform", json!({ "moveH": 1.0, "copies": 1000 }))); + s.effects.push(fx(crate::REVOLVE, json!({}))); + } + let fill = three_d_meshes(&fill_only).unwrap(); + let triangles = fill[0].mesh.triangles.len(); + assert!(triangles > 1); + let whole = three_d_meshes(&n); + assert!(whole.as_ref().is_err_and(|e| !e.contains("to export")), "the stroke's own error: {whole:?}"); + // With room for less than the fill's mesh, the stroke's is never built. + let mut total = 0; + let capped = meshes_within(&n, &mut total, triangles - 1); + assert!(capped.as_ref().is_err_and(|e| e.contains("too complex to export")), "{capped:?}"); + assert_eq!(total, triangles); +} + +#[test] +fn the_objects_of_one_asset_share_one_triangle_budget() { + let mut a = square_node(1, 0.0); + a.appearance.effects.push(fx(EXTRUDE, json!({}))); + let mut b = square_node(2, 300.0); + b.appearance.effects.push(fx(EXTRUDE, json!({}))); + let triangles = |n: &Node| three_d_meshes(n).unwrap().iter().map(|m| m.mesh.triangles.len()).sum::(); + let (ta, tb) = (triangles(&a), triangles(&b)); + assert!(meshes_of_within([&a], ta).is_ok()); + assert!(meshes_of_within([&b], tb).is_ok()); + assert!(meshes_of_within([&a, &b], ta.max(tb)).is_err_and(|e| e.contains("too complex to export"))); + assert_eq!(meshes_of_within([&a, &b], ta + tb).map(|m| m.len()), Ok(4)); + assert_eq!(three_d_meshes_of([&a, &b]).map(|m| m.len()), Ok(4)); } #[test] diff --git a/crates/effects/src/stroke/gradient.rs b/crates/effects/src/stroke/gradient.rs index 677eea308..1cb79e9c7 100644 --- a/crates/effects/src/stroke/gradient.rs +++ b/crates/effects/src/stroke/gradient.rs @@ -13,6 +13,8 @@ //! fill them ([`gradient_slices`]); Outline Stroke turns the strip into a gradient mesh //! ([`gradient_meshes`]). +use std::collections::VecDeque; + use kurbo::{BezPath, Point, Shape, Vec2}; use vectorcraft_doc::color::{Gradient, GradientGeom, GradientPaint, Paint}; use vectorcraft_doc::live::{H_DOWN, H_LEFT, H_RIGHT, H_UP}; @@ -78,14 +80,26 @@ struct Lay<'a> { st: &'a StrokeLayer, /// Half the stroked width ([`aligned_width`]). hw: f64, - /// How far ribs reach either side of the path where nothing wider is near. - cover: f64, + /// How far ribs reach past the stroke's edge. margin: f64, /// How far a strip runs on past the start and end of an open subpath (caps and heads). ext: (f64, f64), } impl Lay<'_> { + /// How far the stroke reaches either side of the path at fraction `t` of a subpath: its wider + /// side, just before or just after a step there. + fn half(&self, t: f64) -> f64 { + let Some(p) = &self.st.profile else { return self.hw }; + let ((bl, br), (al, ar)) = p.around(t); + self.hw * bl.max(br).max(al).max(ar).max(0.0) + } + + /// How far a rib at fraction `t` reaches either side of the path to cover the stroke there. + fn cover(&self, t: f64) -> f64 { + self.half(t) + self.margin + } + /// The across band at fraction `t` of a subpath whose visible side is `side` (`Some(true)`: /// left; `None`: both, a centred stroke). fn band(&self, t: f64, side: Option) -> (f64, f64) { @@ -147,10 +161,11 @@ fn strips(bp: &BezPath, rule: FillRule, st: &StrokeLayer, tol: f64, margin: f64, if !(hw > 0.0 && hw.is_finite()) { return vec![]; } - let full = hw * st.profile_max(); let heads = (st.head_reach(false), st.head_reach(true)); - let cap = cap_extent(st.cap, 2.0 * full); - let lay = Lay { st, hw, cover: full + margin, margin, ext: (cap.max(heads.0) + margin, cap.max(heads.1) + margin) }; + let mut lay = Lay { st, hw, margin, ext: (0.0, 0.0) }; + // Caps reach as far past the ends as the stroke is wide there. + let cap = |t: f64| cap_extent(st.cap, 2.0 * lay.half(t)); + lay.ext = (cap(0.0).max(heads.0) + margin, cap(1.0).max(heads.1) + margin); let subs = subpaths(bp); let count = subs.len(); let mut out = vec![]; @@ -165,7 +180,8 @@ fn strips(bp: &BezPath, rule: FillRule, st: &StrokeLayer, tol: f64, margin: f64, // A subpath of no length: a dot (round and projecting caps paint it). 1 => { let (d, b, e) = (Vec2::new(1.0, 0.0), lay.band(0.0, side), lay.ext.0.max(lay.ext.1)); - vec![Rib::square(pts[0] - d * e, d, lay.cover, 0.0, b), Rib::square(pts[0] + d * e, d, lay.cover, 0.0, b)] + let r = lay.cover(0.0); + vec![Rib::square(pts[0] - d * e, d, r, 0.0, b), Rib::square(pts[0] + d * e, d, r, 0.0, b)] } _ => { let total = length(&pts, closed); @@ -215,9 +231,37 @@ fn strip(lay: &Lay, pts: &[Point], closed: bool, total: f64, side: Option, let n = pts.len(); let seg_count = if closed { n } else { n - 1 }; let dir: Vec = (0..seg_count).map(|i| unit(pts[(i + 1) % n] - pts[i])).collect(); - // Ribs near an arrowhead reach across it. - let reach = |s: f64| { - let mut r = lay.cover; + // How far each point is from the next, and along the subpath. + let seg: Vec = (0..n).map(|i| pts[i].distance(pts[(i + 1) % n])).collect(); + let along: Vec = seg + .iter() + .scan(0.0, |s, l| { + let at = *s; + *s += l; + Some(at) + }) + .collect(); + // How far the ribs at point `i` reach: past the stroke there and on its way to the points + // either side, where it is no wider than at one of the two (each side of a width profile + // stays between its widths at the points of a span, and its points have points here). The + // caps and dots of a dashed stroke reach along the path as far as it is wide where they sit, + // so there the ribs cover the widest the stroke gets that far either way. Not as far as the + // stroke is wide anywhere: where a stroke crosses or runs close by itself, a narrow part + // reaching that far would paint over a wide part beside it (#5960). The start of a closed + // subpath is also its end. Ribs near an arrowhead reach across it. + let cover: Vec = (0..n).map(|i| if closed && i == 0 { lay.cover(0.0).max(lay.cover(1.0)) } else { lay.cover(along[i] / total) }).collect(); + let window = match lay.st.dash { + Some(_) if lay.st.profile.is_some() => cap_extent(lay.st.cap, 2.0 * lay.hw * lay.st.profile_max()), + _ => 0.0, + }; + let ahead = widest_ahead(&cover, &seg, closed, window); + // Behind a point is ahead of it on the subpath run backwards. + let back_seg: Vec = (0..n).map(|i| seg[(2 * n - 2 - i) % n]).collect(); + let back_cover: Vec = cover.iter().rev().copied().collect(); + let behind: Vec = widest_ahead(&back_cover, &back_seg, closed, window).into_iter().rev().collect(); + let reach = |i: usize| { + let mut r = cover[i].max(ahead[i]).max(behind[i]); + let s = along[i]; if heads.0 > 0.0 && s <= heads.0 { r = r.max(heads.0 + lay.margin); } @@ -227,29 +271,69 @@ fn strip(lay: &Lay, pts: &[Point], closed: bool, total: f64, side: Option, r }; let mut out = Vec::with_capacity(n + 4); - let mut s = 0.0; if !closed { let (d, b) = (dir[0], lay.band(0.0, side)); - out.push(Rib::square(pts[0] - d * lay.ext.0, d, reach(0.0), 0.0, b)); - out.push(Rib::square(pts[0], d, reach(0.0), 0.0, b)); - s = pts[0].distance(pts[1]); + out.push(Rib::square(pts[0] - d * lay.ext.0, d, reach(0), 0.0, b)); + out.push(Rib::square(pts[0], d, reach(0), 0.0, b)); } let (first, last) = if closed { (0, n) } else { (1, n - 1) }; for i in first..last { let v = i % n; - let t = s / total; - let ribs = lay.turn(pts[v], dir[(v + seg_count - 1) % seg_count], dir[v % seg_count], reach(s), t, lay.band(t, side)); + let t = along[v] / total; + let ribs = lay.turn(pts[v], dir[(v + seg_count - 1) % seg_count], dir[v % seg_count], reach(v), t, lay.band(t, side)); // A closed subpath starts after the fan of its first corner and ends with it. let skip = if closed && i == 0 { ribs.len() - 1 } else { 0 }; out.extend(ribs.into_iter().skip(skip)); - s += pts[v].distance(pts[(v + 1) % n]); } if closed { - out.extend(lay.turn(pts[0], dir[seg_count - 1], dir[0], reach(total), 1.0, lay.band(1.0, side))); + out.extend(lay.turn(pts[0], dir[seg_count - 1], dir[0], reach(0), 1.0, lay.band(1.0, side))); } else { let (d, b) = (dir[seg_count - 1], lay.band(1.0, side)); - out.push(Rib::square(pts[n - 1], d, reach(total), 1.0, b)); - out.push(Rib::square(pts[n - 1] + d * lay.ext.1, d, reach(total), 1.0, b)); + out.push(Rib::square(pts[n - 1], d, reach(n - 1), 1.0, b)); + out.push(Rib::square(pts[n - 1] + d * lay.ext.1, d, reach(n - 1), 1.0, b)); + } + out +} + +/// For each of the points of a subpath, how far the ribs of the points ahead of it reach +/// (`cover`), out to the first point more than `window` along (or the end of an open subpath, +/// or once round a closed one): `seg[i]` is how far point `i` is from the next (past the last, +/// the first when `closed`). Both ends of the window only move forward, so a queue of the +/// points that reach farther than every point after them gives each maximum, and a subpath +/// costs time in proportion to its points however many a window spans. +pub(super) fn widest_ahead(cover: &[f64], seg: &[f64], closed: bool, window: f64) -> Vec { + let n = cover.len().min(seg.len()); + if n == 0 { + return vec![]; + } + // A closed subpath's points ahead are numbered on round it once more. + let last = if closed { 2 * n - 1 } else { n - 1 }; + let mut pos = Vec::with_capacity(last + 1); + let mut s = 0.0; + for k in 0..=last { + pos.push(s); + s += seg[k % n]; + } + let mut out = Vec::with_capacity(n); + let mut queue: VecDeque = VecDeque::new(); + let mut next = 1; + for i in 0..n { + next = next.max(i + 1); + let stop = if closed { i + n } else { last }; + let mut past = next > i + 1 && pos[next - 1] - pos[i] > window; + while !past && next <= stop { + let r = cover[next % n]; + while queue.back().is_some_and(|&k| cover[k % n] <= r) { + queue.pop_back(); + } + queue.push_back(next); + past = pos[next] - pos[i] > window; + next += 1; + } + while queue.front().is_some_and(|&k| k <= i) { + queue.pop_front(); + } + out.push(queue.front().map_or(0.0, |&k| cover[k % n])); } out } diff --git a/crates/effects/src/stroke/tests_gradient.rs b/crates/effects/src/stroke/tests_gradient.rs index bf63b46b8..a0056d6b3 100644 --- a/crates/effects/src/stroke/tests_gradient.rs +++ b/crates/effects/src/stroke/tests_gradient.rs @@ -237,3 +237,113 @@ fn slices_cover_an_arrowhead_on_a_long_straight_leg() { } } } + +/// Does the last slice holding `p` (the one filled last there) lay its gradient piece on a part of +/// the path running across the page (left or right), for a gradient in `mode`? Along, the piece +/// runs the way the path does; across, square to it. +fn painted_by_level_part(slices: &[Slice], p: Point, mode: StrokeGradientMode) -> Option { + slices.iter().rev().find(|s| s.shape.contains(p)).map(|s| { + let d = s.to - s.from; + (d.x.abs() > d.y.abs()) == (mode == StrokeGradientMode::Along) + }) +} + +/// #5960: where a variable-width stroke crosses itself, the later pass paints over the earlier one +/// only where the two overlap. Its slices reach as far as the stroke is wide there, not as far as +/// it is wide anywhere, so a narrow later pass leaves the wide earlier one its own colour beside it. +#[test] +fn a_narrow_pass_over_a_wide_one_paints_only_where_they_overlap() { + // Right along y = 50, round, then down through the first leg at x = 50, near the narrow end. + let crossing = poly(&[(0.0, 50.0), (100.0, 50.0), (100.0, 100.0), (50.0, 100.0), (50.0, 0.0)], false); + for mode in [StrokeGradientMode::Along, StrokeGradientMode::Across] { + let mut st = stroke(10.0, mode); + st.profile = Some(WidthProfile::taper_end()); + let sl = slices(&crossing, &st); + // In the first leg (about 4 pt either side of it there) but 4 pt beside the second leg, + // which is less than 1 pt either side of itself there. + let level = painted_by_level_part(&sl, Point::new(54.0, 52.0), mode); + assert_eq!(level, Some(true), "{mode:?}: painted by the second leg"); + // Where the two overlap, the second leg is on top. + let level = painted_by_level_part(&sl, Point::new(50.5, 52.0), mode); + assert_eq!(level, Some(false), "{mode:?}: painted by the first leg"); + assert_covered(&crossing, &st); + } +} + +/// #5960: a narrow pass running close beside a wide one doesn't paint the wide one's edge. +#[test] +fn a_narrow_pass_beside_a_wide_one_leaves_its_edge_alone() { + // Right along y = 0, then back along y = 7: the way back is the narrow end of a taper. + let hairpin = poly(&[(0.0, 0.0), (100.0, 0.0), (100.0, 7.0), (0.0, 7.0)], false); + let total = 207.0; + let mut st = stroke(10.0, StrokeGradientMode::Along); + st.profile = Some(WidthProfile::taper_end()); + let sl = slices(&hairpin, &st); + // Half way along the first leg, 3 pt off it (it is about 3.8 pt wide there either side); the + // way back is about 1.2 pt wide either side there, so it reaches no nearer than 5.8. + let p = Point::new(50.0, 3.0); + let t = param(&sl, p).expect("in a slice"); + assert!((t - 50.0 / total).abs() < 1e-6, "painted with {t}, not the first leg's {}", 50.0 / total); + assert_covered(&hairpin, &st); +} + +/// #5960: with dashes, the ribs reach as far as the stroke gets wide within a cap's length along +/// the path, however many points lie that far: not as far as it is wide anywhere when more than +/// 256 do. +#[test] +fn a_densely_flattened_dashed_pass_beside_a_wide_one_leaves_its_edge_alone() { + // The hairpin above, the way back in 0.01 pt steps: 500 points within a round cap's 5 pt. + let mut pts = vec![(0.0, 0.0), (100.0, 0.0)]; + pts.extend((0..=10_000).map(|i| (100.0 - i as f64 * 0.01, 7.0))); + let hairpin = poly(&pts, false); + let total = 207.0; + let mut st = stroke(10.0, StrokeGradientMode::Along); + st.profile = Some(WidthProfile::taper_end()); + st.cap = LineCap::Round; + st.dash = Some(Dash { pattern: vec![12.0, 6.0], ..Default::default() }); + let sl = slices(&hairpin, &st); + let p = Point::new(50.0, 3.0); + let t = param(&sl, p).expect("in a slice"); + assert!((t - 50.0 / total).abs() < 1e-6, "painted with {t}, not the first leg's {}", 50.0 / total); +} + +/// The widest the ribs ahead reach is the widest over the points out to the first one more than +/// the window along, as a walk along the subpath finds it, open and closed. +#[test] +fn widest_ahead_is_the_widest_within_the_window() { + let mut seed = 0x9e37_79b9_u64; + let mut rnd = move || { + seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407); + (seed >> 11) as f64 / (1u64 << 53) as f64 + }; + for _ in 0..200 { + let n = 1 + (rnd() * 40.0) as usize; + let cover: Vec = (0..n).map(|_| rnd() * 10.0).collect(); + // Some points on top of each other. + let seg: Vec = (0..n).map(|_| if rnd() < 0.2 { 0.0 } else { rnd() * 3.0 }).collect(); + let window = [0.0, rnd() * 5.0, rnd() * 200.0][(rnd() * 3.0) as usize]; + for closed in [false, true] { + let got = super::gradient::widest_ahead(&cover, &seg, closed, window); + assert_eq!(got.len(), n); + for (i, &got) in got.iter().enumerate() { + let (mut j, mut d, mut want) = (i, 0.0, 0.0_f64); + for _ in 0..n { + let k = if closed { + (j + 1) % n + } else if j + 1 < n { + j + 1 + } else { + break; + }; + want = want.max(cover[k]); + d += seg[j]; + j = k; + if d > window { + break; + } + } + assert_eq!(got, want, "n {n}, closed {closed}, window {window}, point {i}"); + } + } + } +} diff --git a/crates/effects/src/svg_filter.rs b/crates/effects/src/svg_filter.rs index 0c84cde69..325ce9b38 100644 --- a/crates/effects/src/svg_filter.rs +++ b/crates/effects/src/svg_filter.rs @@ -57,16 +57,27 @@ fn numbers(v: &str) -> Option> { /// Filter `xml` as it is drawn and written ([`rebuilt`]) when it passes [`check`], `None` when it /// doesn't. Remembered for the filters seen last, so drawing does not check a filter again each time. fn checked(xml: &str) -> Option> { - use std::hash::BuildHasher; - type Seen = (std::collections::hash_map::RandomState, std::collections::HashMap<(u64, usize), Option>>, usize); static SEEN: std::sync::OnceLock> = std::sync::OnceLock::new(); - let seen = SEEN.get_or_init(Default::default); + remembered(&SEEN, xml, |xml| checked_form(xml).ok().map(|(_, form)| Arc::from(form))) +} + +/// The results [`remembered`] keeps: the hash's seed, the results by key, and their size in bytes. +type Seen = (std::collections::hash_map::RandomState, std::collections::HashMap<(u64, usize), Option>>, usize); + +/// `make(xml)`, remembered in `seen` for the filters seen last. +fn remembered( + seen: &'static std::sync::OnceLock>, + xml: &str, + make: impl FnOnce(&str) -> Option>, +) -> Option> { + use std::hash::BuildHasher; + let seen = seen.get_or_init(Default::default); // Keyed by a hash that a file can't aim at (randomly seeded) and the length. let key = seen.lock().ok().map(|s| (s.0.hash_one(xml), xml.len())); if let Some(hit) = key.and_then(|k| seen.lock().ok().and_then(|s| s.1.get(&k).cloned())) { return hit; } - let form: Option> = checked_form(xml).ok().map(|(_, form)| Arc::from(form)); + let form = make(xml); if let (Some(k), Ok(mut s)) = (key, seen.lock()) && !s.1.contains_key(&k) { @@ -83,7 +94,7 @@ fn checked(xml: &str) -> Option> { form } -/// The most check results [`checked`] keeps, and the most bytes of filters it keeps. +/// The most results [`remembered`] keeps in one place, and the most bytes of filters it keeps there. const MAX_CHECKED: usize = 65_536; const MAX_CHECKED_BYTES: usize = 32 << 20; @@ -279,9 +290,30 @@ const LATER_BLEND_MODES: [&str; 11] = /// The `feComposite` operators of SVG 1.1. const SVG11_OPERATORS: [&str; 6] = ["over", "in", "out", "atop", "xor", "arithmetic"]; -/// The longest form SVG output writes ([`svg11`]), leaving room within [`MAX_DRAWN_XML`] for the -/// region and id written into it. -const MAX_WRITTEN_XML: usize = MAX_DRAWN_XML - 1024; +/// The longest id SVG output gives a filter it writes: a unique-id prefix of up to 13 characters, +/// `filter-` and a counter of up to 20 digits, with room to spare. +const WRITTEN_ID_ROOM: usize = 64; + +/// The longest text `{}` writes for a finite `f64`: a sign, `0.`, 307 zeros and 17 digits, as for +/// `-f64::MIN_POSITIVE`. +const MAX_NUMBER_TEXT: usize = 327; + +/// The most the four region attributes written into a filter ([`SvgFilterFx::written_xml`]) add to +/// it: each of `x`, `y`, `width` and `height` as ` height="…"` with the longest number. +const REGION_ROOM: usize = 4 * (" height=\"\"".len() + MAX_NUMBER_TEXT); + +/// The longest form SVG output writes ([`svg11`]): room is left within [`SvgFilter::MAX_XML`], the +/// longest filter SVG import reads, for the id written into it. Measured with [`written_len`]. +const MAX_WRITTEN_XML: usize = SvgFilter::MAX_XML - WRITTEN_ID_ROOM; + +/// How long `s` is as SVG output can write it and import reads it back: SVG output with the Latin-1 +/// encoding writes each character past U+00FF as `&#x…;`, which import keeps as text, so the +/// longer of the two lengths is counted for each such character. +fn written_len(s: &str) -> usize { + // `&#x`, one hex digit per four significant bits, and `;`. + let reference = |c: u32| 4 + ((u32::BITS - c.leading_zeros()) as usize).div_ceil(4); + s.chars().map(|c| if u32::from(c) < 0x100 { c.len_utf8() } else { reference(u32::from(c)) }).sum() +} /// Filter `xml` (a rebuilt one, [`rebuilt`]) as SVG 1.1 output writes it, in the form an SVG 1.1 /// viewer draws as resvg draws `xml`: @@ -413,7 +445,7 @@ fn svg11(xml: &str) -> Option { } out.push_str(""); } - (out.len() <= MAX_WRITTEN_XML).then_some(out) + (written_len(&out) <= MAX_WRITTEN_XML).then_some(out) } /// The result names of a filter's SVG 1.1 form ([`svg11`]): the names its elements use, and the @@ -1278,13 +1310,28 @@ impl SvgFilterFx { /// whose box in the document is `bounds`: in its SVG 1.1 form ([`svg11`]), `None` when SVG 1.1 /// can't hold it ([`Self::writes_as_svg11`]). pub fn written_xml(&self, bounds: Rect) -> Option { - self.with_region(&svg11(&self.xml)?, (bounds - self.origin).scale_from_origin(1.0 / self.scale)) + let out = self.with_region(&self.svg11_form()?, (bounds - self.origin).scale_from_origin(1.0 / self.scale))?; + (written_len(&out) <= MAX_WRITTEN_XML).then_some(out) } - /// Can SVG 1.1 output write the filter as a `` element ([`svg11`])? When it can't, SVG - /// output treats it as it treats the other pixel effects. + /// Can SVG 1.1 output write the filter as a `` element ([`svg11`]) that SVG import + /// reads back? When it can't, SVG output treats it as it treats the other pixel effects. pub fn writes_as_svg11(&self) -> bool { - svg11(&self.xml).is_some() + self.svg11_form().is_some() + } + + /// The filter's SVG 1.1 form ([`svg11`]) when, with any region and id that SVG output writes + /// into it, it is a filter SVG import reads ([`check`]): at most [`SvgFilter::MAX_XML`] bytes, + /// [`MAX_PRIMITIVES`] primitives and [`MAX_DEPTH`] levels deep, and drawn. Remembered for the + /// filters seen last, so export does not check a filter again for each object. + fn svg11_form(&self) -> Option> { + static FORMS: std::sync::OnceLock> = std::sync::OnceLock::new(); + // `measured` follows from the XML, so the XML is the whole key. + let region = if self.measured { REGION_ROOM } else { 0 }; + remembered(&FORMS, &self.xml, |xml| { + let form = svg11(xml)?; + (written_len(&form) + region <= MAX_WRITTEN_XML && check(&form).is_ok()).then(|| Arc::from(form)) + }) } /// How finely the filter runs on a raster whose pixels are `space.px` points wide, for an diff --git a/crates/effects/src/svg_filter/tests.rs b/crates/effects/src/svg_filter/tests.rs index 4d0a60394..df1956f3f 100644 --- a/crates/effects/src/svg_filter/tests.rs +++ b/crates/effects/src/svg_filter/tests.rs @@ -457,9 +457,48 @@ fn a_filter_at_the_size_limit_draws_with_its_measured_region() { assert_eq!(xml.len(), SvgFilter::MAX_XML); assert_eq!(check(&xml), Ok("r".into())); let f = filter(json!({ "xml": xml })); - let written = f.written_xml(Rect::new(0.123456789, 0.123456789, 1000.987654321, 1000.987654321)).unwrap(); - assert!(written.len() > SvgFilter::MAX_XML, "the region written out is longer: {} bytes", written.len()); - assert!(with_id(&written, DRAWN_ID).is_some(), "the drawn form is read"); + let drawn = f.drawn_xml(Rect::new(0.123456789, 0.123456789, 1000.987654321, 1000.987654321)).unwrap(); + assert!(drawn.len() > SvgFilter::MAX_XML, "the region written out is longer: {} bytes", drawn.len()); + assert!(with_id(&drawn, DRAWN_ID).is_some(), "the drawn form is read"); +} + +/// SVG output writes a filter live only when the form it writes, with its region and id, is one +/// SVG import reads back: at most [`SvgFilter::MAX_XML`] bytes. A filter at the limit whose region +/// is measured against the box, one at the limit whose id is a character long, and one whose +/// shadows make its SVG 1.1 form longer are left to be written as other pixel effects are. +#[test] +fn svg_output_writes_a_filter_only_in_a_form_import_reads() { + let at_limit = |open: &str, close: &str| { + let xml = format!("{open}{}{close}", "a".repeat(SvgFilter::MAX_XML - open.len() - close.len())); + assert_eq!((xml.len(), check(&xml).is_ok()), (SvgFilter::MAX_XML, true)); + filter(json!({ "xml": xml })) + }; + let measured = at_limit( + r#""#, + ); + let short_id = at_limit(r#""#); + let shadows = format!(r#"{}"#, "".repeat(12), "a".repeat(SvgFilter::MAX_XML - 400)); + assert!(check(&shadows).is_ok() && shadows.len() <= SvgFilter::MAX_XML); + for f in [measured, short_id, filter(json!({ "xml": shadows }))] { + assert!(!f.writes_as_svg11() && f.written_xml(Rect::new(0.0, 0.0, 10.0, 10.0)).is_none(), "{} bytes", f.xml().len()); + } + // Room for the id and the longest region: still written, and read back. + let open = r#""#; + let xml = format!("{open}{}{close}", "a".repeat(MAX_WRITTEN_XML - REGION_ROOM - open.len() - close.len())); + let f = filter(json!({ "xml": xml })); + assert!(f.writes_as_svg11()); + let id = format!("u_vc_unique_-filter-{}", usize::MAX); + let out = with_id(&f.written_xml(Rect::new(0.123456789, 0.123456789, 1000.987654321, 1000.987654321)).unwrap(), &id).unwrap(); + assert!(out.len() <= SvgFilter::MAX_XML && check(&out).is_ok(), "{} bytes", out.len()); + // The longest numbers a region can take still fit. + let far = f.written_xml(Rect::new(-f64::MIN_POSITIVE, -f64::MIN_POSITIVE, f64::MAX / 4.0, f64::MAX / 4.0)).unwrap(); + assert!(with_id(&far, &id).is_some_and(|out| out.len() <= SvgFilter::MAX_XML)); + for v in [-f64::MIN_POSITIVE, -5e-324, -2.225073858507201e-308, f64::MIN, -1.0 / 3.0] { + assert!(format!("{v}").len() <= MAX_NUMBER_TEXT, "{v:e}"); + } + assert_eq!(format!("{}", -f64::MIN_POSITIVE).len(), MAX_NUMBER_TEXT); } /// Only a type selector naming a filter or one of its elements, or `*`, lets a style sheet reach diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index 0b3fc0b1c..1afa679aa 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -455,6 +455,92 @@ fn art_past_the_budget_keeps_its_own_gradients_in_place() { } } +/// A hidden path in a layer that keeps its shape past the budget does not hold the layer's +/// gradients in place: the layer's bounds leave it out, so the layer's own gradient moves with +/// the visible art under a Transform effect and Free Distort, as does the art's when the layer is +/// the art. +#[test] +fn a_hidden_path_past_the_budget_leaves_a_layers_gradients_free_to_move() { + use std::sync::Arc; + + use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; + use vectorcraft_doc::{AppearanceItem, FillLayer, LayerColor, NodeId, NodeKind, color::Paint}; + let stops = vec![GradientStop::new(0.0, vectorcraft_color::Color::BLACK), GradientStop::new(1.0, vectorcraft_color::Color::WHITE)]; + let geom = GradientGeom { start: (0.0, 50.0).into(), end: (100.0, 50.0).into(), aspect: 1.0, focal: None }; + let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..GradientPaint::new(Gradient::new(GradientKind::Linear, stops)) })); + let end = |ap: &Appearance| match ap.fill_paint() { + Paint::Gradient(g) => g.geom.map(|g| g.end.x), + _ => None, + }; + // A layer with a visible square and a hidden circle, both 0..100, and a gradient fill of its own. + let layer = || { + let square = Arc::new(Node::path(NodeId(3), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default())); + let mut circle = Node::path(NodeId(4), shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default()); + circle.visible = false; + let mut layer = Node::layer(NodeId(2), "Layer 1", LayerColor::Preset(0)); + if let NodeKind::Layer { children, .. } = &mut layer.kind { + *children = vec![square, Arc::new(circle)]; + } + layer.appearance.items.push(AppearanceItem::Fill(FillLayer::new(placed.clone()))); + layer + }; + // No room beyond what the paths cost: the square takes each effect, the circle would be drawn + // with more lines and keeps its shape. + let budget = Budget { anchors: usize::MAX, lines: 0 }; + // Each scales the art ten times about its center: 0..100 becomes -450..550. + let corners = json!({"corners": [[-4.5, -4.5], [5.5, -4.5], [5.5, 5.5], [-4.5, 5.5]]}); + for grow in [fx("distort.transform", json!({"scaleH": 1000, "scaleV": 1000})), fx("distort.freeDistort", corners)] { + let mut art = layer(); + let carry = crate::reshape::reshape_leaves_within(&mut art, &[&grow], budget); + let circle = art.children().unwrap()[1].geometric_bounds().unwrap(); + assert!(circle.width() < 101.0, "{}: the hidden circle keeps its shape: {circle:?}", grow.id); + let square = art.children().unwrap()[0].geometric_bounds().unwrap(); + assert!((square.x1 - 550.0).abs() < 1e-6, "{}: the square takes the effect: {square:?}", grow.id); + let mut own = Appearance::clone(&art.appearance); + carry.apply(&mut own); + assert_eq!(end(&own), Some(550.0), "{}: the layer's gradient, as the art", grow.id); + let mut art = Node::group(NodeId(9), vec![Arc::new(layer())]); + crate::reshape::reshape_leaves_within(&mut art, &[&grow], budget); + assert_eq!(end(&art.children().unwrap()[0].appearance), Some(550.0), "{}: the layer's gradient, inside the art", grow.id); + } +} + +/// A freeform gradient whose points were edited (its vector still fitted to the bounds) moves its +/// points with a Transform effect and Free Distort, on a path, on a group inside the art and on the +/// art itself, as a placed gradient does. +#[test] +fn edited_freeform_points_follow_a_transform_effect_and_free_distort() { + use std::sync::Arc; + + use vectorcraft_color::{Color, Freeform, FreeformPoint, Gradient, GradientKind, GradientPaint}; + use vectorcraft_doc::{AppearanceItem, FillLayer, NodeId, color::Paint}; + let mut ramp = GradientPaint::new(Gradient { kind: GradientKind::Freeform, ..Gradient::default() }); + let pt = |x: f64, c| FreeformPoint::new(Point::new(x, 50.0), c); + ramp.set_freeform(Freeform { points: vec![pt(0.0, Color::BLACK), pt(100.0, Color::WHITE)], ..Default::default() }); + assert!(ramp.geom.is_none() && !ramp.is_placed()); + let freeform = Paint::Gradient(Box::new(ramp)); + let xs = |ap: &Appearance| match ap.fill_paint() { + Paint::Gradient(g) => g.freeform.as_ref().map(|f| f.points.iter().map(|p| p.at.x).collect::>()), + _ => None, + }; + let square = |ap: Appearance| Arc::new(Node::path(NodeId(3), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), ap)); + // Each moves the art 30 pt right. + let corners = json!({"corners": [[0.3, 0.0], [1.3, 0.0], [1.3, 1.0], [0.3, 1.0]]}); + for e in [fx("distort.transform", json!({"moveH": 30})), fx("distort.freeDistort", corners)] { + let mut inner = Node::group(NodeId(2), vec![square(Appearance::default())]); + inner.appearance.items.push(AppearanceItem::Fill(FillLayer::new(freeform.clone()))); + let mut art = Node::group(NodeId(9), vec![square(Appearance::basic(freeform.clone(), Paint::None, 0.0)), Arc::new(inner)]); + art.appearance.items.push(AppearanceItem::Fill(FillLayer::new(freeform.clone()))); + let carry = crate::reshape::reshape_leaves_within(&mut art, &[&e], Budget::EFFECTS); + let members = art.children().unwrap(); + assert_eq!(xs(&members[0].appearance), Some(vec![30.0, 130.0]), "{}: the path's points", e.id); + assert_eq!(xs(&members[1].appearance), Some(vec![30.0, 130.0]), "{}: the inner group's points", e.id); + let mut own = Appearance::clone(&art.appearance); + carry.apply(&mut own); + assert_eq!(xs(&own), Some(vec![30.0, 130.0]), "{}: the art's own points", e.id); + } +} + /// #3578: a large Roughen draws each of its points as many lines; they stay within the budget, /// the Roughen built again with fewer points where they wouldn't. #[test] diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index eb61b5126..75835394a 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -12,6 +12,7 @@ pub mod clipboard; mod colorcmds; pub mod colormgmt; pub mod colortheme; +pub mod compositefonts; mod compoundshape; pub(crate) mod create; mod css; @@ -286,6 +287,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(inline::specs()); v.extend(textstyles::specs()); v.extend(fonts::specs()); + v.extend(compositefonts::specs()); v.extend(fontfiles::specs()); v.extend(help::specs()); v.extend(threads::specs()); @@ -420,8 +422,22 @@ pub(crate) fn text_param<'a>(p: &'a Value, key: &str, cmd: &str) -> Result Option { p.get(key).and_then(Value::as_u64).map(NodeId) } -pub(crate) fn ids_param(p: &Value, key: &str) -> Option> { - p.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(NodeId).collect()) +/// The object ids `p` gives for `key`, or `None` when `p` leaves it out or gives null. A value that +/// is not an array, or an entry that is not a non-negative integer, is an error with the message +/// [`checked_ids_param`] gives, so no entry is dropped. Whether each id names an object is left to +/// the caller. +pub(crate) fn ids_param(p: &Value, key: &str, cmd: &str) -> Result>> { + let values = match p.get(key) { + None | Some(Value::Null) => return Ok(None), + Some(Value::Array(a)) => a, + Some(_) => return Err(bad(cmd, format!("{key} must be an array of non-negative integer object ids"))), + }; + values.iter().enumerate().map(|(index, value)| id_entry(value, key, index, cmd)).collect::>>().map(Some) +} +/// Entry `index` (from 0) of the `key` list as an object id: anything but a non-negative integer is +/// an error naming the key, the entry and its value. +fn id_entry(value: &Value, key: &str, index: usize, cmd: &str) -> Result { + value.as_u64().map(NodeId).ok_or_else(|| bad(cmd, format!("`{key}` entry {index} is {value}, not a non-negative integer object id"))) } /// The objects `p` gives `command`: its `ids`, else its `id` as `ids` of one (as [`targets`] reads /// them), each checked ([`checked_id`]): a malformed one, or one naming no object, is an error, @@ -447,7 +463,7 @@ pub(crate) fn index(v: u64) -> usize { pub(crate) fn checked_ids_param(s: &Session, p: &Value, key: &str, command: &str) -> Result> { let values = p.get(key).and_then(Value::as_array).ok_or_else(|| bad(command, format!("{key} must be an array of non-negative integer object ids")))?; - values.iter().map(|value| checked_id(s, value, command)).collect() + values.iter().enumerate().map(|(index, value)| id_entry(value, key, index, command).and_then(|_| checked_id(s, value, command))).collect() } /// The objects a command acts on: `ids` when given (every one checked, and those inside another diff --git a/crates/engine/src/cmd/addons/cleanup/text.rs b/crates/engine/src/cmd/addons/cleanup/text.rs index 4b76562e9..73b21356b 100644 --- a/crates/engine/src/cmd/addons/cleanup/text.rs +++ b/crates/engine/src/cmd/addons/cleanup/text.rs @@ -962,11 +962,9 @@ fn replace_fonts(s: &mut Session, p: &Value) -> Result { let default = CharStyle::default().font_family; let wanted = text_param(p, "font", C)?.filter(|f| !f.trim().is_empty()).unwrap_or(&default); let db = FontDb::global(); - // Any name of the family is stored as its own name. - let family = match db.resolve(wanted, "Regular") { - Some((f, m)) if m != FontMatch::Missing => f.family.clone(), - _ => return Err(bad(C, "the replacement font is not available")), - }; + // Any name of the family is stored as its own name, and a composite font as its own name. + let composite = db.composite_font(wanted).is_some(); + let family = db.stored_family(wanted, "Regular").ok_or_else(|| bad(C, "the replacement font is not available"))?; let missing = |st: &CharStyle| db.resolve(&st.font_family, &st.font_style).is_none_or(|(_, m)| m == FontMatch::Missing); let lacks = |t: &TextObject| t.runs.iter().any(|r| missing(&r.style)); let d = &s.doc()?.doc; @@ -988,8 +986,11 @@ fn replace_fonts(s: &mut Session, p: &Value) -> Result { let mut runs = 0; let mut replace = |t: &mut TextObject| { for r in t.runs.iter_mut().filter(|r| missing(&r.style)) { - // The style the font has closest to the one the character had. - r.style.font_style = db.face(&family, &r.style.font_style).map_or_else(|| r.style.font_style.clone(), |f| f.style.clone()); + // The style the font has closest to the one the character had (a composite font's + // parts name their own styles: the character keeps its style). + if !composite { + r.style.font_style = db.face(&family, &r.style.font_style).map_or_else(|| r.style.font_style.clone(), |f| f.style.clone()); + } r.style.font_family = family.clone(); r.style.font_version = None; r.style.font_axes.clear(); @@ -1004,7 +1005,6 @@ fn replace_fonts(s: &mut Session, p: &Value) -> Result { } } edit_symbol_texts(d, None, &|t, _| lacks(t), &mut replace); - drop(replace); Ok(runs) })?; Ok(json!({ "runs": runs, "font": family })) diff --git a/crates/engine/src/cmd/addons/color.rs b/crates/engine/src/cmd/addons/color.rs index 574003b93..cd30d29d1 100644 --- a/crates/engine/src/cmd/addons/color.rs +++ b/crates/engine/src/cmd/addons/color.rs @@ -174,7 +174,7 @@ fn filter(s: &mut Session, p: &Value) -> Result { let label = effects::effect_info(id).map_or("Apply as Filter", |e| e.label.trim_end_matches('…')); let kinds = effects::addons::duotone::swatch_kinds(&s.doc()?.doc); let swatches = |name: &str| kinds.get(name).copied(); - recolor_links(s, &given, label, &|c| map.apply(c), &|c, link, tint| map.apply_link(c, link, tint, &swatches)) + recolor_links(s, &given, C, label, &|c| map.apply(c), &|c, link, tint| map.apply_link(c, link, tint, &swatches)) } /// Apply Fill to Stroke (`to_stroke`) or Apply Stroke to Fill: each target copies one of its own diff --git a/crates/engine/src/cmd/addons/color/nearest.rs b/crates/engine/src/cmd/addons/color/nearest.rs index 5a9d0e7c7..0236698be 100644 --- a/crates/engine/src/cmd/addons/color/nearest.rs +++ b/crates/engine/src/cmd/addons/color/nearest.rs @@ -189,7 +189,7 @@ pub(super) fn apply(s: &mut Session, p: &Value) -> Result { let given = json!({ "ids": ids.iter().map(|i| i.0).collect::>(), "fill": fill, "stroke": stroke, "includeImages": false, "includePatterns": false, }); - let r = recolor_links(s, &given, "Apply Nearest Swatch", &|c| to(c).and_then(Paint::color).unwrap_or(c), &links)?; + let r = recolor_links(s, &given, APPLY, "Apply Nearest Swatch", &|c| to(c).and_then(Paint::color).unwrap_or(c), &links)?; squash(s, undo, "Apply Nearest Swatch"); Ok(json!({ "changed": r["changed"], "swatches": names, "added": added })) } diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 7f7b34352..02990a662 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -1230,7 +1230,7 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { let selected = if defaults_only { vec![] } else { - match ids_param(p, "ids") { + match ids_param(p, "ids", C)? { Some(v) => outermost(&s.doc()?.doc, v), None => selected_roots(s)?, } diff --git a/crates/engine/src/cmd/assets.rs b/crates/engine/src/cmd/assets.rs index fd273e067..8c225b877 100644 --- a/crates/engine/src/cmd/assets.rs +++ b/crates/engine/src/cmd/assets.rs @@ -277,12 +277,11 @@ fn export_3d(s: &mut Session, p: &Value) -> Result { for id in ids { let Some(a) = d.asset(id) else { continue }; let format = a.format_3d.as_deref().unwrap_or(DEFAULT_FORMAT_3D); - let mut parts = vec![]; - for node in &a.nodes { - let n = d.node(*node).ok_or_else(|| bad(C, format!("no such object {0}", node.0)))?; - let what = &a.name; - parts.extend(vectorcraft_render::effects::three_d_meshes(n).map_err(|e| bad(C, format!("{what} can't be exported in 3D: {e}")))?); - } + let nodes = + a.nodes.iter().map(|node| d.node(*node).ok_or_else(|| bad(C, format!("no such object {0}", node.0)))).collect::>>()?; + // The asset's objects share one budget of triangles. + let what = &a.name; + let parts = vectorcraft_render::effects::three_d_meshes_of(nodes).map_err(|e| bad(C, format!("{what} can't be exported in 3D: {e}")))?; let base = Some(safe_file_name(&a.name)).filter(|b| !b.is_empty()).unwrap_or_else(|| "Asset".into()); let mut stem = base.clone(); let mut k = 1; diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index 5dcb82136..53f373a3b 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -263,7 +263,7 @@ pub fn specs() -> Vec { "Symbol Instance Appearance", [], None, - "{id?|ids?: [one id] (default: the one selected object), path: [i…] (the object in the symbol's art: child indices in paint order from the art's root down through its groups; [] the root), fill?: \"#rrggbb\"|{…paint params of paint.setFill: color, none, swatch, tint, gradient, keepModel}|null (null: the symbol's own again), stroke?: as fill, opacity?: 0..100|null, blend?: name|null} change the appearance of one object of a dynamic symbol instance's art, for that instance only: it stays linked, so editing the symbol reshapes it and keeps the change; refused for a static symbol or a path naming no object → {id, overrides: how many objects the instance changes}", + "{id?|ids?: [one id] (default: the one selected object), path: [i…] (the object in the symbol's art: child indices in paint order from the art's root down through its groups; [] the root), fill?: \"#rrggbb\"|{…paint params of paint.setFill: color, none, swatch, tint, gradient, keepModel}|null (null: the symbol's own again), stroke?: as fill, opacity?: 0..100|null, blend?: name|null} change the appearance of one object of a dynamic symbol instance's art, for that instance only: it stays linked, so editing the symbol reshapes it and keeps the change; refused for a static symbol, a path naming no object, or a fill or stroke other than null for an object that does not draw it (a guide, blend, envelope, Repeat, Intertwine group, gradient mesh or placed document; a symbol instance's stroke, and its fill unless a solid color or none, which stains its art; an image's fill or stroke without a Convert to Shape or Outline Object effect), with nothing of the call applied; opacity, blend and null are taken for every object → {id, overrides: how many objects the instance changes}", has_selection, symbol_instance_set_appearance ), diff --git a/crates/engine/src/cmd/brushsym/dynamic.rs b/crates/engine/src/cmd/brushsym/dynamic.rs index 6197e57be..89c7c3c52 100644 --- a/crates/engine/src/cmd/brushsym/dynamic.rs +++ b/crates/engine/src/cmd/brushsym/dynamic.rs @@ -129,6 +129,64 @@ fn path_param(p: &Value, art: &Node, cmd: &str) -> Result> { Ok(path) } +/// The refusal of `key` (`fill` or `stroke`) for `n`, an object of a dynamic symbol's art, when `n` +/// does not draw that paint of its own; `None` when it does. Paths (not guides), +/// compound paths, type, groups and layers draw them; a symbol instance draws its fill as a stain +/// on its art ([`solid_fill_check`]) and no stroke; an image draws one only when that fill or +/// stroke has a Convert to Shape or Outline Object effect. +fn paint_refusal(n: &Node, key: &str, cmd: &str) -> Option { + Some(match &n.kind { + NodeKind::Path { guide: true, .. } => bad(cmd, format!("{key} cannot be set on a guide")), + NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_) | NodeKind::Group { .. } | NodeKind::Layer { .. } => return None, + NodeKind::Image(_) => { + let effects = if key == "fill" { n.appearance.fill().map(|f| &f.effects) } else { n.appearance.stroke().map(|s| &s.effects) }; + if effects.is_some_and(|e| vectorcraft_render::effects::gives_image_shape(e)) { + return None; + } + bad(cmd, format!("{key} cannot be set on an image without a Convert to Shape or Outline Object effect on it")) + } + NodeKind::SymbolInstance { .. } if key == "fill" => return None, + NodeKind::SymbolInstance { .. } => bad(cmd, "stroke cannot be set on a symbol instance"), + NodeKind::Blend { .. } => bad(cmd, format!("{key} cannot be set on a blend")), + NodeKind::Envelope { .. } => bad(cmd, format!("{key} cannot be set on an envelope")), + NodeKind::Mesh(_) => bad(cmd, format!("{key} cannot be set on a gradient mesh")), + NodeKind::Repeat(_) => bad(cmd, format!("{key} cannot be set on a Repeat")), + NodeKind::PlacedDocument(_) => bad(cmd, format!("{key} cannot be set on a placed document")), + NodeKind::Intertwine { .. } => bad(cmd, format!("{key} cannot be set on an Intertwine group")), + }) +} + +/// Refuse a `fill` or `stroke` other than null that the object at `path` of `art` does not draw +/// ([`paint_refusal`]); null, which takes a change back, is taken for every object. +fn check_paint_kinds(p: &Value, art: &Node, path: &[u32], cmd: &str) -> Result<()> { + let Some(n) = symbol_overrides::part(art, path) else { return Ok(()) }; + for key in ["fill", "stroke"] { + if p.get(key).is_some_and(|v| !v.is_null()) + && let Some(e) = paint_refusal(n, key, cmd) + { + return Err(e); + } + } + Ok(()) +} + +/// Refuse `fill`, the paint `p` asks for the object at `path` of `art`, when that object is a +/// symbol instance and `fill` is a gradient or a pattern: an instance stains its art with a solid +/// fill only, so such a fill draws nothing. +fn solid_fill_check(p: &Value, art: &Node, path: &[u32], fill: Option<&vectorcraft_color::Paint>, cmd: &str) -> Result<()> { + let asked = p.get("fill").is_some_and(|v| !v.is_null()); + let is_instance = symbol_overrides::part(art, path).is_some_and(|n| matches!(n.kind, NodeKind::SymbolInstance { .. })); + let draws_nothing = |f: &vectorcraft_color::Paint| match f { + vectorcraft_color::Paint::Gradient(_) => true, + vectorcraft_color::Paint::Pattern { .. } => true, + vectorcraft_color::Paint::None | vectorcraft_color::Paint::Solid { .. } => false, + }; + if asked && is_instance && fill.is_some_and(draws_nothing) { + return Err(bad(cmd, "fill on a symbol instance must be a solid color or none")); + } + Ok(()) +} + /// `fill` or `stroke` of `p` for override `o`: absent leaves it, null clears it, a colour string or /// an object of `paint.setFill`'s paint params sets it. fn paint_change(s: &Session, p: &Value, key: &str, cmd: &str) -> Result>> { @@ -170,9 +228,11 @@ pub(super) fn symbol_instance_set_appearance(s: &mut Session, p: &Value) -> Resu } let (id, art) = dynamic_instance(s, p, C)?; let path = path_param(p, &art, C)?; + check_paint_kinds(p, &art, &path, C)?; let d = &s.doc()?.doc; let old = d.node(id).and_then(|n| n.extras().symbol_overrides.iter().find(|o| o.path == path).cloned()); let new = changed(s, p, old.unwrap_or_else(|| SymbolOverride { path: path.clone(), ..Default::default() }), C)?; + solid_fill_check(p, &art, &path, new.fill.as_ref(), C)?; let count = edit_part(s, id, &path, "Symbol Instance Appearance", |o| *o = new)?; Ok(json!({ "id": id.0, "overrides": count })) } diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 1b37ca96f..2aa759411 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -144,7 +144,7 @@ pub fn specs() -> Vec { "Make", ["Object", "Image Trace"], None, - "{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), savedPreset?: {settings} (a saved preset's settings, which the journal records so a replay traces with them after the preset is changed, renamed or deleted; used in place of the saved preset `preset` names), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, at most ten; white is its own color, and a named color with every channel 245 or more is taken as that white, except with logoColors named, logoWhite false and no ignoreWhite; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white while white is its own color, and stay holes with ignoreWhite or without a white of its own; fills/strokes are ignored; gradients and photos are refused), logoWhite: true (mode logo with logoColors named; false traces the named colors only: white areas take the nearest of them, a named white is kept and enclosed transparent areas stay holes; with ignoreWhite, white areas are left out either way)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at)}", + "{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), savedPreset?: {settings} (a saved preset's settings, which the journal records so a replay traces with them after the preset is changed, renamed or deleted; used in place of the saved preset `preset` names), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), ignoreBackground: false (with ignoreWhite: only the areas of the ignored color that reach the image's edge are left out), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, at most ten; white is its own color, and a named color with every channel 245 or more is taken as that white, except with logoColors named, logoWhite false and no ignoreWhite; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white while white is its own color, and stay holes with ignoreWhite or without a white of its own; fills/strokes are ignored; gradients and photos are refused), logoWhite: true (mode logo with logoColors named; false traces the named colors only: white areas take the nearest of them, a named white is kept and enclosed transparent areas stay holes; with ignoreWhite, white areas are left out either way)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at)}", has_selection_or_ids, |s, p| trace_make(s, p, false) ), diff --git a/crates/engine/src/cmd/colorcmds.rs b/crates/engine/src/cmd/colorcmds.rs index d70a81700..221521efb 100644 --- a/crates/engine/src/cmd/colorcmds.rs +++ b/crates/engine/src/cmd/colorcmds.rs @@ -32,7 +32,7 @@ pub fn specs() -> Vec { None, "{fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?} replace each colour by its RGB inverse (keeps the colour model) in fills, strokes, text, gradient meshes (with fills), colorized image fills (their pixels stay unchanged), other embedded images (a recoloured copy) and pattern tiles (a new pattern swatch; the original stays), as one undo step → {changed}", has_selection, - |s, p| recolor(s, p, "Invert Colors", &invert) + |s, p| recolor(s, p, "edit.colors.invert", "Invert Colors", &invert) ), cmd!( "edit.colors.toCMYK", @@ -41,7 +41,7 @@ pub fn specs() -> Vec { None, "{fill?, stroke?, includeImages?, includePatterns?, ids?} (as edit.colors.invert) → {changed}", has_selection, - |s, p| recolor(s, p, "Convert to CMYK", &to_cmyk) + |s, p| recolor(s, p, "edit.colors.toCMYK", "Convert to CMYK", &to_cmyk) ), cmd!( "edit.colors.toGrayscale", @@ -50,7 +50,7 @@ pub fn specs() -> Vec { None, "{fill?, stroke?, includeImages?, includePatterns?, ids?} (as edit.colors.invert) → {changed}", has_selection, - |s, p| recolor(s, p, "Convert to Grayscale", &to_gray) + |s, p| recolor(s, p, "edit.colors.toGrayscale", "Convert to Grayscale", &to_gray) ), cmd!( "edit.colors.toRGB", @@ -60,7 +60,7 @@ pub fn specs() -> Vec { "{fill?, stroke?, includeImages?, includePatterns?, ids?} (as edit.colors.invert) → {changed}", has_selection, |s, p| { - recolor(s, p, "Convert to RGB", &|c| { + recolor(s, p, "edit.colors.toRGB", "Convert to RGB", &|c| { let [r, g, b] = c.to_rgb(); Color::rgb(r, g, b) }) @@ -331,26 +331,27 @@ impl ColorVisitor for Visit<'_, '_> { } } -pub(crate) fn recolor(s: &mut Session, p: &Value, label: &str, f: &dyn Fn(Color) -> Color) -> Result { - run_pass(s, p, label, Recolor::new(Scope::of(p), f)) +pub(crate) fn recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn(Color) -> Color) -> Result { + run_pass(s, p, cmd, label, Recolor::new(Scope::of(p), f)) } /// [`recolor`] with solid colours and gradient stops mapped through `links`, with their swatch /// link and tint. -pub(crate) fn recolor_links(s: &mut Session, p: &Value, label: &str, f: &dyn Fn(Color) -> Color, links: &LinkMap) -> Result { - run_pass(s, p, label, Recolor::with_links(Scope::of(p), f, links)) +pub(crate) fn recolor_links(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn(Color) -> Color, links: &LinkMap) -> Result { + run_pass(s, p, cmd, label, Recolor::with_links(Scope::of(p), f, links)) } -/// Run `pass` on the `ids` param's objects (default: the selection) as one undo step. -fn run_pass(s: &mut Session, p: &Value, label: &str, pass: Recolor) -> Result { - let ids = match ids_param(p, "ids") { +/// Run `pass` on the `ids` param's objects (default: the selection) as one undo step named `label`; +/// errors name the command `cmd`. +fn run_pass(s: &mut Session, p: &Value, cmd: &str, label: &str, pass: Recolor) -> Result { + let ids = match ids_param(p, "ids", cmd)? { Some(v) => v, None => selected_roots(s)?, }; let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok(); let n = s.edit(label, |d, _| { let n = pass.run(d, &ids); - super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(label, e))?; + super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(cmd, e))?; Ok(n) })?; Ok(json!({ "changed": n })) @@ -358,7 +359,7 @@ fn run_pass(s: &mut Session, p: &Value, label: &str, pass: Recolor) -> Result Result { let i = (f64_req(p, "intensity", "edit.colors.saturate")?.clamp(-100.0, 100.0) / 100.0) as f32; - recolor(s, p, "Saturate", &move |c| { + recolor(s, p, "edit.colors.saturate", "Saturate", &move |c| { let [h, sat, v] = c.to_hsb(); let to = (sat * (1.0 + i)).clamp(0.0, 1.0); // A color whose saturation stays the same is returned as it is, so it keeps its swatch link. @@ -416,12 +417,12 @@ fn adjust_balance(s: &mut Session, p: &Value) -> Result { (*tint, *c) = (t, base.tinted(t)); true }; - return run_pass(s, p, "Adjust Colors", Recolor::links(Scope::of(p), &shift)); + return run_pass(s, p, C, "Adjust Colors", Recolor::links(Scope::of(p), &shift)); } other => return Err(bad(C, format!("unknown mode `{other}` (rgb|cmyk|gray|global)"))), }; let convert = bool_or(p, "convert", false); - recolor(s, p, "Adjust Colors", &move |c| { + recolor(s, p, C, "Adjust Colors", &move |c| { let out = adjust(c); if convert { out } else { keep_model(c, out) } }) diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs index 7bdcfad58..d9b5784b6 100644 --- a/crates/engine/src/cmd/colormgmt.rs +++ b/crates/engine/src/cmd/colormgmt.rs @@ -287,7 +287,8 @@ fn assign_profile(s: &mut Session, p: &Value) -> Result { /// How Document Color Mode converts neutral RGB greys (R = G = B) to CMYK (the `grays` param). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum Grays { - /// Through the CMYK profile like any colour: four-colour greys and a rich black. + /// Through the CMYK profile like any colour: light grays in cyan, magenta and yellow only, darker + /// grays in four inks, and a rich black. #[default] Profile, /// On the black plate only: K is the grey's ink percentage (as Convert to Grayscale and then diff --git a/crates/engine/src/cmd/compositefonts.rs b/crates/engine/src/cmd/compositefonts.rs new file mode 100644 index 000000000..2b356ab85 --- /dev/null +++ b/crates/engine/src/cmd/compositefonts.rs @@ -0,0 +1,309 @@ +//! Type › Composite Fonts…: the document's composite fonts ([`vectorcraft_doc::CompositeFont`]), +//! which set each class of character (Kanji, Kana, Punctuation, Symbols, Roman, Numerals) in a +//! font of its own. Type names a composite font as its family, and the font menus list them first. +//! The font database holds the active document's composite fonts ([`sync`]); a file exported here +//! carries them to another document. +//! +//! Behavior reference (public documentation): +//! . + +use serde_json::{Map, Value, json}; +use vectorcraft_doc::text::{COMPOSITE_FONTS_MAX, COMPOSITE_NAME_MAX}; +use vectorcraft_doc::{CharClass, CompositeFont, CompositePart, CompositeParts}; +use vectorcraft_text::FontDb; + +use super::fileio::{read_file_head, write_file}; +use super::textstyles::each_text_mut; +use super::typecmd::refresh_bounds; +use super::{CommandSpec, Result, Session, bad, cmd, has_doc, switch_or, text_param}; + +/// The extension of the files Export writes and Import reads. +pub const EXTS: &[&str] = &["vccomposite"]; + +/// The largest file Import reads, in bytes. +const MAX_FILE_BYTES: usize = 4 << 20; + +/// The keys a part may give. +const PART_KEYS: [&str; 7] = ["font", "style", "size", "baseline", "hScale", "vScale", "scaleAtCenter"]; + +pub fn specs() -> Vec { + vec![ + cmd!( + query "type.compositeFonts.list", + "Composite Fonts", + [], + None, + "{} the active document's composite fonts → {fonts: [{name, parts: {kanji, kana, punctuation, symbols, roman, numerals: {font, style, size: % of the type size, baseline: % of the type size (positive raises), hScale: %, vScale: %, scaleAtCenter?: bool}}}]}", + has_doc, + list + ), + cmd!( + "type.compositeFonts.set", + "Composite Fonts…", + ["Type"], + None, + "{fonts: [{name, parts: {kanji: {font, style?: \"Regular\", size?: % 10..400 = 100, baseline?: % -100..100 = 0}, kana?, punctuation?, symbols?, roman?, numerals?: {font, style?, size?, baseline?, hScale?, vScale?: % 10..400 = 100, scaleAtCenter?: bool = false}}}]} replace the active document's composite fonts (Type › Composite Fonts…'s OK), as one undo step, none when nothing changes. A composite font sets each class of character in its part's font: kanji (the base: ideographs, full-width letters and digits and anything no other class takes), kana (hiragana and katakana, both widths), punctuation (East Asian and full-width punctuation, quotes and dashes), symbols (arrows, math, shapes and other symbols), roman (half-width letters, their punctuation and spaces) and numerals (the digits 0 to 9); a part left out is the kanji part. size and baseline are percent of the type's size, hScale and vScale scale kana, roman and numerals only, and scaleAtCenter (kana only) scales them from the em box's center instead of the baseline. Names are trimmed, unique in any case, at most 255 characters, and may not name an installed font; a part's font may not be a composite font; at most 256. Type set in a composite font names it as its family (text.setStyle {font: name}), and the font menus list composite fonts first. A font taken out stays named by its type, which then shows as missing → {fonts}", + has_doc, + set + ), + cmd!( + query "type.compositeFonts.export", + "Export Composite Fonts", + [], + None, + "{path?: .vccomposite file, names?: [name] (default: all), fonts?: [{name, parts}] (as type.compositeFonts.set takes them)} write the active document's composite fonts (all, or those named, any case), or the fonts given, to a file type.compositeFonts.import reads; without path the file's text comes back as data → {path, count} | {data, count}", + has_doc, + export + ), + cmd!( + "type.compositeFonts.import", + "Import Composite Fonts", + [], + None, + "{path: .vccomposite file | data: its text | dataBase64: its bytes (at most 4 MB), apply?: bool = true} read the composite fonts a file holds (type.compositeFonts.export writes them); with apply they join the active document's, a font of the same name (any case) replaced, as one undo step, none when nothing changes; apply false only reads them → {fonts, imported: [name]}", + has_doc, + import + ), + ] +} + +/// Register the active document's composite fonts in the font database, and remove the ones the +/// other open documents (or an earlier active document) defined. Run after each command and when +/// another document takes the front, so an undo, a revert, an opened or closed file or a document +/// switch draws type in the active document's own composite fonts, and type naming one it does +/// not define shows as missing. Whether a composite font was added, changed or removed. +pub(crate) fn sync(s: &mut Session) -> bool { + let db = FontDb::global(); + let mut names = Vec::new(); + let mut changed = false; + for f in s.active().map(|d| d.doc.composite_fonts.to_vec()).unwrap_or_default() { + changed |= db.set_composite_font(&f); + names.push(f.name.to_lowercase()); + } + let others = s.docs.iter().flat_map(|d| d.doc.composite_fonts.iter().map(|f| f.name.to_lowercase())); + let stale: Vec = s.composite_names.iter().cloned().chain(others).filter(|n| !names.contains(n)).collect(); + for gone in stale { + changed |= db.remove_composite_font(&gone); + } + s.composite_names = names; + changed +} + +/// Leave out a file's unusable composite fonts before registering and measuring its type. +pub(crate) fn filter_loaded(doc: &mut vectorcraft_doc::Document) { + let db = FontDb::global(); + if let Some(kept) = CompositeFont::usable(&doc.composite_fonts) { + *doc.composite_fonts = kept; + } + doc.composite_fonts.retain(|f| !db.has_installed_family(&f.name)); +} + +fn list(s: &mut Session, _: &Value) -> Result { + Ok(json!({ "fonts": *s.doc()?.doc.composite_fonts })) +} + +/// Composite font `v` from command parameters or a file: its name trimmed, each part left out a +/// copy of the Kanji part, and keys or values a part can't take refused. +fn parse_font(v: &Value, c: &str) -> Result { + let o = v.as_object().ok_or_else(|| bad(c, "each font must be {name, parts}"))?; + if let Some(k) = o.keys().find(|k| !["name", "parts"].contains(&k.as_str())) { + return Err(bad(c, format!("unknown key `{k}`; a font takes name and parts"))); + } + let raw = o.get("name").and_then(Value::as_str).ok_or_else(|| bad(c, "each font needs a name"))?; + let name = CompositeFont::clean_name(raw) + .ok_or_else(|| bad(c, format!("`{raw}` can't name a composite font (1 to {COMPOSITE_NAME_MAX} characters, no control characters)")))?; + let parts = o.get("parts").and_then(Value::as_object).ok_or_else(|| bad(c, format!("`{name}`: parts must be an object")))?; + if let Some(k) = parts.keys().find(|k| !CharClass::ALL.iter().any(|c| c.key() == k.as_str())) { + return Err(bad(c, format!("`{name}`: unknown class `{k}`; the classes are kanji, kana, punctuation, symbols, roman and numerals"))); + } + let part = |class: CharClass| -> Result> { + let Some(p) = parts.get(class.key()) else { return Ok(None) }; + let po = p.as_object().ok_or_else(|| bad(c, format!("`{name}`: {} must be an object", class.key())))?; + if let Some(k) = po.keys().find(|k| !PART_KEYS.contains(&k.as_str())) { + return Err(bad(c, format!("`{name}`: unknown key `{k}` in {}", class.key()))); + } + serde_json::from_value(p.clone()).map(Some).map_err(|e| bad(c, format!("`{name}`: {}: {e}", class.key()))) + }; + let kanji = part(CharClass::Kanji)?.ok_or_else(|| bad(c, format!("`{name}`: give the kanji part")))?; + let mut f = CompositeFont { name: name.clone(), parts: CompositeParts::all(kanji) }; + for class in CharClass::ALL.into_iter().skip(1) { + if let Some(p) = part(class)? { + *f.parts.get_mut(class) = p; + } + } + f.check().map_err(|e| bad(c, format!("`{}`: {e}", f.name)))?; + Ok(f) +} + +/// Error unless `fonts` can stand together in a document: names unique in any case, none naming +/// an installed font, no part set in a composite font, and at most [`COMPOSITE_FONTS_MAX`]. +fn check_set(fonts: &[CompositeFont], c: &str) -> Result<()> { + if fonts.len() > COMPOSITE_FONTS_MAX { + return Err(bad(c, format!("a document keeps at most {COMPOSITE_FONTS_MAX} composite fonts"))); + } + let db = FontDb::global(); + let mut names = std::collections::HashSet::new(); + for f in fonts { + if !names.insert(f.name.to_lowercase()) { + return Err(bad(c, format!("two composite fonts are named `{}`", f.name))); + } + if db.has_installed_family(&f.name) { + return Err(bad(c, format!("`{}` is the name of an installed font", f.name))); + } + } + for f in fonts { + for class in CharClass::ALL { + let font = &f.parts.get(class).font; + if names.contains(&font.to_lowercase()) || db.composite_font(font).is_some() { + return Err(bad(c, format!("`{}`: {} is set in `{font}`, a composite font", f.name, class.key()))); + } + } + } + Ok(()) +} + +/// Make `after` the active document's composite fonts, as one undo step named `label` (none when +/// they are what it has). The font database forgets the ones taken out, and type set in a font +/// that changed is measured again. +fn replace(s: &mut Session, label: &str, after: Vec) -> Result<()> { + let before = s.doc()?.doc.composite_fonts.to_vec(); + if before == after { + return Ok(()); + } + let changed: Vec = + before.iter().filter(|b| !after.contains(b)).chain(after.iter().filter(|a| !before.contains(a))).map(|f| f.name.to_lowercase()).collect(); + let db = FontDb::global(); + for f in &before { + if !after.iter().any(|a| CompositeFont::same_name(&a.name, &f.name)) { + db.remove_composite_font(&f.name); + } + } + for f in &after { + db.set_composite_font(f); + } + let r = s.edit(label, |d, _| { + *d.composite_fonts = after.clone(); + each_text_mut(d, &mut |t| { + if t.runs.iter().any(|r| changed.contains(&r.style.font_family.to_lowercase())) { + refresh_bounds(t); + } + Ok(()) + }) + }); + sync(s); + r +} + +fn set(s: &mut Session, p: &Value) -> Result { + const C: &str = "type.compositeFonts.set"; + let list = p.get("fonts").and_then(Value::as_array).ok_or_else(|| bad(C, "give fonts: [{name, parts}…]"))?; + let fonts = list.iter().map(|v| parse_font(v, C)).collect::>>()?; + check_set(&fonts, C)?; + replace(s, "Composite Fonts", fonts.clone())?; + Ok(json!({ "fonts": fonts })) +} + +fn export(s: &mut Session, p: &Value) -> Result { + const C: &str = "type.compositeFonts.export"; + let path = text_param(p, "path", C)?; + if path.is_some_and(|p| p.trim().is_empty()) { + return Err(bad(C, "path is empty")); + } + let all = match p.get("fonts") { + None | Some(Value::Null) => s.doc()?.doc.composite_fonts.to_vec(), + Some(Value::Array(list)) => { + let fonts = list.iter().map(|v| parse_font(v, C)).collect::>>()?; + check_set(&fonts, C)?; + fonts + } + Some(_) => return Err(bad(C, "fonts must be a list of composite fonts")), + }; + let fonts = match p.get("names") { + None | Some(Value::Null) => all, + Some(Value::Array(names)) => { + let mut out = Vec::with_capacity(names.len()); + for n in names { + let n = n.as_str().ok_or_else(|| bad(C, "names must be strings"))?; + let f = all + .iter() + .find(|f| CompositeFont::same_name(&f.name, n.trim())) + .ok_or_else(|| bad(C, format!("no composite font named `{n}`")))?; + if !out.contains(f) { + out.push(f.clone()); + } + } + out + } + Some(_) => return Err(bad(C, "names must be a list of names")), + }; + if fonts.is_empty() { + return Err(bad(C, "no composite fonts to export")); + } + let mut file = Map::new(); + file.insert("compositeFonts".into(), json!(fonts)); + let text = serde_json::to_string_pretty(&file).map_err(|e| bad(C, e.to_string()))?; + match path { + Some(path) => { + write_file(path, text.as_bytes())?; + Ok(json!({ "path": path, "count": fonts.len() })) + } + None => Ok(json!({ "data": text, "count": fonts.len() })), + } +} + +/// The composite fonts in the bytes of an exported file. +fn read_fonts(bytes: &[u8], c: &str) -> Result> { + let v: Value = serde_json::from_slice(bytes).map_err(|e| bad(c, format!("not a composite fonts file: {e}")))?; + let list = v.get("compositeFonts").and_then(Value::as_array).ok_or_else(|| bad(c, "not a composite fonts file: no compositeFonts list"))?; + if list.len() > COMPOSITE_FONTS_MAX { + return Err(bad(c, format!("the file holds more than {COMPOSITE_FONTS_MAX} composite fonts"))); + } + list.iter().map(|f| parse_font(f, c)).collect() +} + +fn import(s: &mut Session, p: &Value) -> Result { + const C: &str = "type.compositeFonts.import"; + let apply = switch_or(p, "apply", true, C)?; + let given = (text_param(p, "path", C)?, text_param(p, "data", C)?, text_param(p, "dataBase64", C)?); + let too_large = || bad(C, "the file is larger than 4 MB"); + let bytes = match given { + (Some(path), None, None) if !path.trim().is_empty() => read_file_head(path, MAX_FILE_BYTES + 1)?, + (None, Some(text), None) => text.as_bytes().to_vec(), + (None, None, Some(b64)) => { + if b64.len() > MAX_FILE_BYTES / 3 * 4 + 4 { + return Err(too_large()); + } + vectorcraft_format::base64_decode(b64).ok_or_else(|| bad(C, "dataBase64 is not base64"))? + } + _ => return Err(bad(C, "give one of path, data or dataBase64")), + }; + if bytes.len() > MAX_FILE_BYTES { + return Err(too_large()); + } + let fonts = read_fonts(&bytes, C)?; + if fonts.is_empty() { + return Err(bad(C, "the file holds no composite fonts")); + } + let mut names = std::collections::HashSet::new(); + if let Some(f) = fonts.iter().find(|f| !names.insert(f.name.to_lowercase())) { + return Err(bad(C, format!("the file holds two composite fonts named `{}`", f.name))); + } + if apply { + let mut after = s.doc()?.doc.composite_fonts.to_vec(); + for f in &fonts { + match after.iter_mut().find(|a| CompositeFont::same_name(&a.name, &f.name)) { + Some(a) => *a = f.clone(), + None => after.push(f.clone()), + } + } + check_set(&after, C)?; + replace(s, "Import Composite Fonts", after)?; + } else { + check_set(&fonts, C)?; + } + let imported: Vec<&str> = fonts.iter().map(|f| f.name.as_str()).collect(); + Ok(json!({ "fonts": fonts, "imported": imported })) +} + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/cmd/compositefonts/tests.rs b/crates/engine/src/cmd/compositefonts/tests.rs new file mode 100644 index 000000000..670a85ee2 --- /dev/null +++ b/crates/engine/src/cmd/compositefonts/tests.rs @@ -0,0 +1,502 @@ +use serde_json::{Value, json}; +use vectorcraft_doc::{NodeId, NodeKind}; + +use crate::Session; + +/// A composite font named `name`: Source Sans 3 for the base, JetBrains Mono at half size for +/// Roman, Inter for numerals. +fn font(name: &str) -> Value { + json!({"name": name, "parts": { + "kanji": {"font": "Source Sans 3"}, + "roman": {"font": "JetBrains Mono", "size": 50, "baseline": 10}, + "numerals": {"font": "Inter", "hScale": 80}, + }}) +} + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + s +} + +fn names(s: &Session) -> Vec { + s.doc().unwrap().doc.composite_fonts.iter().map(|f| f.name.clone()).collect() +} + +fn steps(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +fn text(s: &Session, id: u64) -> vectorcraft_doc::TextObject { + match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("not text"), + } +} + +/// A scratch folder of the test's own. +fn folder(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("vectorcraft-composite-fonts-{name}-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// Composite Fonts' OK replaces the document's composite fonts as one undo step (none when nothing +/// changes); parts left out copy the Kanji part; they are saved with the document and undone with +/// it. +#[test] +fn composite_fonts_are_set_as_one_undo_step_and_saved() { + let mut s = session(); + let before = steps(&s); + let r = s.execute("type.compositeFonts.set", &json!({"fonts": [font(" CF Saved A "), font("CF Saved B")]})).unwrap(); + assert_eq!(r["fonts"][0]["name"], "CF Saved A"); + assert_eq!(names(&s), ["CF Saved A", "CF Saved B"]); + assert_eq!(steps(&s), before + 1); + let f = &s.doc().unwrap().doc.composite_fonts[0]; + assert_eq!((f.parts.kana.font.as_str(), f.parts.roman.size, f.parts.numerals.h_scale), ("Source Sans 3", 50.0, 80.0)); + // The same fonts again change nothing. + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Saved A"), font("CF Saved B")]})).unwrap(); + assert_eq!(steps(&s), before + 1); + let listed = s.execute("type.compositeFonts.list", &json!({})).unwrap(); + assert_eq!(listed["fonts"].as_array().map(Vec::len), Some(2)); + // They travel with the file. + let back = vectorcraft_format::load(&vectorcraft_format::save_file(&s.doc().unwrap().doc)).unwrap(); + assert_eq!(*back.composite_fonts, *s.doc().unwrap().doc.composite_fonts); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(names(&s).is_empty()); +} + +/// Bad fonts are refused with an error and no undo step: a name that is empty, shared (in any +/// case) or an installed font's; a part set in a composite font; an unknown class or key; a value +/// out of range, of the wrong type, or on a class that can't take it; no Kanji part. +#[test] +fn bad_composite_fonts_are_refused() { + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Refused Base")]})).unwrap(); + let before = steps(&s); + let with = |f: &dyn Fn(&mut Value)| { + let mut v = font("CF Refused"); + f(&mut v); + json!({"fonts": [v]}) + }; + for bad in [ + json!({}), + json!({"fonts": "CF"}), + json!({"fonts": [font(" ")]}), + json!({"fonts": [font("CF Twice"), font("cf twice")]}), + json!({"fonts": [font("Source Sans 3")]}), + json!({"fonts": [font("Tab\tName")]}), + with(&|v| v["parts"]["roman"]["font"] = json!("CF Refused Base")), + with(&|v| v["parts"]["roman"]["font"] = json!("CF Refused")), + with(&|v| v["parts"]["hangul"] = json!({"font": "Inter"})), + with(&|v| v["parts"]["Roman"] = json!({"font": "Inter"})), + with(&|v| v["parts"]["roman"]["width"] = json!(3)), + with(&|v| v["parts"]["roman"]["size"] = json!(5)), + with(&|v| v["parts"]["roman"]["size"] = json!("50")), + with(&|v| v["parts"]["roman"]["baseline"] = json!(-120)), + with(&|v| v["parts"]["kanji"]["hScale"] = json!(90)), + with(&|v| v["parts"]["punctuation"] = json!({"font": "Inter", "vScale": 90})), + with(&|v| v["parts"]["roman"]["scaleAtCenter"] = json!(true)), + with(&|v| v["parts"]["kana"] = json!({"font": "Inter", "scaleAtCenter": "yes"})), + with(&|v| v["parts"]["roman"]["font"] = json!("")), + with(&|v| v["extra"] = json!(1)), + with(&|v| { + v["parts"].as_object_mut().unwrap().remove("kanji"); + }), + ] { + assert!(s.execute("type.compositeFonts.set", &bad).is_err(), "accepted {bad}"); + assert_eq!(names(&s), ["CF Refused Base"]); + assert_eq!(steps(&s), before); + } + // Kana scaled at the center is allowed. + let ok = with(&|v| v["parts"]["kana"] = json!({"font": "Inter", "vScale": 80, "scaleAtCenter": true})); + s.execute("type.compositeFonts.set", &ok).unwrap(); + assert_eq!(names(&s), ["CF Refused"]); +} + +/// Type set in a composite font names it and is not missing; each class is drawn in its part's +/// font; changing the font measures the type again, and taking it out leaves the type naming it, +/// shown as missing. +#[test] +fn type_set_in_a_composite_font_follows_it() { + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Type")]})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab12", "size": 20})).unwrap()["id"].as_u64().unwrap(); + let r = s.execute("text.setStyle", &json!({"ids": [id], "font": "CF Type"})).unwrap(); + assert!(r.get("warning").is_none(), "{r}"); + let t = text(&s, id); + assert_eq!(t.runs[0].style.font_family, "CF Type"); + let db = vectorcraft_text::FontDb::global(); + let face = |family: &str| db.face(family, "Regular").unwrap().id(); + let lay = vectorcraft_text::layout(db, &t); + let fonts: Vec = lay.glyphs.iter().map(|g| g.font_id).collect(); + assert_eq!(fonts, [face("JetBrains Mono"), face("JetBrains Mono"), face("Inter"), face("Inter")]); + let missing = |s: &mut Session| s.execute("text.fonts", &json!({})).unwrap().as_array().unwrap().iter().filter(|f| f["missing"] == true).count(); + assert_eq!(missing(&mut s), 0); + // Roman at full size makes the type wider, measured again in the same step. + let wide = t.cached_bounds.unwrap().width(); + let mut full = font("CF Type"); + full["parts"]["roman"]["size"] = json!(100); + let before = steps(&s); + s.execute("type.compositeFonts.set", &json!({"fonts": [full]})).unwrap(); + assert_eq!(steps(&s), before + 1); + assert!(text(&s, id).cached_bounds.unwrap().width() > wide + 1.0); + s.execute("edit.undo", &json!({})).unwrap(); + assert!((text(&s, id).cached_bounds.unwrap().width() - wide).abs() < 1e-6); + assert_eq!(db.composite_font("CF Type").unwrap().parts.roman.size, 50.0); + // Taken out, the type keeps the name, now missing; undone, the font is back. + s.execute("type.compositeFonts.set", &json!({"fonts": []})).unwrap(); + assert!(db.composite_font("CF Type").is_none()); + assert_eq!(text(&s, id).runs[0].style.font_family, "CF Type"); + assert_eq!(missing(&mut s), 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(db.composite_font("CF Type").is_some()); + assert_eq!(missing(&mut s), 0); +} + +/// Type in a symbol's art is measured again too when the composite font it names changes. +#[test] +fn type_in_symbol_art_follows_its_composite_font() { + fn width(s: &Session) -> f64 { + fn find(n: &vectorcraft_doc::Node) -> Option { + if let NodeKind::Text(t) = &n.kind { + return t.cached_bounds.map(|b| b.width()); + } + n.children().into_iter().flatten().find_map(|c| find(c)) + } + s.doc().unwrap().doc.symbols.iter().find_map(|sym| find(&sym.art)).unwrap() + } + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Symbol")]})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab12", "size": 20, "font": "CF Symbol"})).unwrap()["id"].as_u64().unwrap(); + s.execute("symbol.new", &json!({"name": "CF Mark", "ids": [id]})).unwrap(); + let narrow = width(&s); + let mut full = font("CF Symbol"); + full["parts"]["roman"]["size"] = json!(100); + s.execute("type.compositeFonts.set", &json!({"fonts": [full]})).unwrap(); + assert!(width(&s) > narrow + 1.0); +} + +/// The font database forgets a composite font once no open document defines it: after the step +/// that added it is undone, and after its document closes. +#[test] +fn composite_fonts_no_document_defines_are_forgotten() { + let db = vectorcraft_text::FontDb::global(); + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Forgotten Undo")]})).unwrap(); + assert!(db.composite_font("CF Forgotten Undo").is_some()); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(db.composite_font("CF Forgotten Undo").is_none()); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(db.composite_font("CF Forgotten Undo").is_some()); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Forgotten Close")]})).unwrap(); + s.execute("file.close", &json!({})).unwrap(); + assert!(db.composite_font("CF Forgotten Close").is_none()); + assert!(db.composite_font("CF Forgotten Undo").is_some()); +} + +/// SVG and legacy `.ai` write type in a composite font as its outlines, since a reader can't find +/// the font by its name. +#[test] +fn exports_outline_type_in_a_composite_font() { + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Export")]})).unwrap(); + s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab1", "size": 20, "font": "CF Export"})).unwrap(); + let svg = s.execute("document.export", &json!({"format": "svg"})).unwrap(); + let svg = String::from_utf8(vectorcraft_format::base64_decode(svg["dataBase64"].as_str().unwrap()).unwrap()).unwrap(); + assert!(!svg.contains("CF Export") && !svg.contains(">(); + let svg = vectorcraft_svg::export(&doc, &vectorcraft_svg::ExportOptions::default()); + assert!(!svg.contains("CF Standalone") && !svg.contains(" = missing["fonts"].as_array().unwrap().iter().filter_map(|f| f["family"].as_str()).collect(); + assert_eq!(names, ["No Such Part Font"], "{missing}"); + let fonts = s.execute("text.fonts", &json!({})).unwrap(); + let row = fonts.as_array().unwrap().iter().find(|r| r["family"] == "CF Inventory").cloned().unwrap(); + assert_eq!((&row["missing"], &row["status"]), (&json!(true), &json!("missing")), "{row}"); + assert_eq!(row["resolved"]["family"], vectorcraft_text::FALLBACK_FAMILY, "{row}"); + // A run that starts with a combining mark after a run ending in kana: the mark is Roman. + let boundary = json!({"name": "CF Boundary", "parts": {"kanji": {"font": "Source Sans 3"}, "roman": {"font": "No Such Part Font"}}}); + s.execute("type.compositeFonts.set", &json!({"fonts": [boundary]})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 170, "text": "\u{304B}\u{301}", "size": 20, "font": "CF Boundary"})).unwrap()["id"] + .as_u64() + .unwrap(); + let row = + |s: &mut Session| s.execute("text.fonts", &json!({})).unwrap().as_array().unwrap().iter().find(|r| r["family"] == "CF Boundary").cloned(); + assert_eq!(row(&mut s).unwrap()["status"], "exact"); + s.execute("text.setRangeStyle", &json!({"id": id, "start": 3, "end": 5, "size": 30})).unwrap(); + assert_eq!(text(&s, id).runs.len(), 2); + assert_eq!(row(&mut s).unwrap()["status"], "missing"); + // A symbol the Kanji part's font draws but the Symbols part's font lacks. + let db = vectorcraft_text::FontDb::global(); + let (mono, sans) = (db.face("JetBrains Mono", "Regular").unwrap(), db.face("Source Sans 3", "Regular").unwrap()); + let c = ('\u{2190}'..='\u{2BFF}').find(|c| mono.covers(*c) && !sans.covers(*c)).expect("a symbol only JetBrains Mono draws"); + let glyphs = json!({"name": "CF Glyphs", "parts": {"kanji": {"font": "JetBrains Mono"}, "symbols": {"font": "Source Sans 3"}}}); + s.execute("type.compositeFonts.set", &json!({"fonts": [glyphs]})).unwrap(); + s.execute("text.create", &json!({"x": 10, "y": 130, "text": c.to_string(), "size": 20, "font": "CF Glyphs"})).unwrap(); + let fonts = s.execute("text.fonts", &json!({})).unwrap(); + let row = fonts.as_array().unwrap().iter().find(|r| r["family"] == "CF Glyphs").cloned().unwrap(); + assert_eq!(row["missingGlyphs"], 1, "{row}"); +} + +/// The Glyphs panel's alternates of a character in a composite font come from the font of the +/// part its grapheme cluster falls in. +#[test] +fn glyph_alternates_of_composite_type_come_from_the_characters_part() { + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Glyph Part")]})).unwrap(); + let id = + s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab12", "size": 20, "font": "CF Glyph Part"})).unwrap()["id"].as_u64().unwrap(); + let r = s.execute("text.glyphAlternates", &json!({"id": id, "start": 2})).unwrap(); + assert_eq!(r["font"], "Inter", "{r}"); + let r = s.execute("text.glyphAlternates", &json!({"id": id, "start": 0})).unwrap(); + assert_eq!(r["font"], "JetBrains Mono", "{r}"); + // A combining mark is drawn in the part of the character it follows. + let id = s.execute("text.create", &json!({"x": 10, "y": 90, "text": "1\u{301}", "size": 20, "font": "CF Glyph Part"})).unwrap()["id"] + .as_u64() + .unwrap(); + let r = s.execute("text.glyphAlternates", &json!({"id": id, "start": 1})).unwrap(); + assert_eq!(r["font"], "Inter", "{r}"); +} + +/// With two documents defining one name differently, the type is drawn in the front document's. +#[test] +fn the_front_document_defines_a_shared_name() { + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Front")]})).unwrap(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let mut other = font("CF Front"); + other["parts"]["roman"]["size"] = json!(75); + s.execute("type.compositeFonts.set", &json!({"fonts": [other]})).unwrap(); + let db = vectorcraft_text::FontDb::global(); + assert_eq!(db.composite_font("CF Front").unwrap().parts.roman.size, 75.0); + s.execute("document.activate", &json!({"index": 0})).unwrap(); + assert_eq!(db.composite_font("CF Front").unwrap().parts.roman.size, 50.0); +} + +/// A composite font only a document in the back defines is not available in the front one, where +/// type naming it shows as missing; a click on the back document's tab brings it back. +#[test] +fn a_font_only_a_back_document_defines_is_missing_in_front() { + let db = vectorcraft_text::FontDb::global(); + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Back Only")]})).unwrap(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + assert!(db.composite_font("CF Back Only").is_none()); + s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab", "size": 20, "font": "CF Back Only"})).unwrap(); + let fonts = s.execute("text.fonts", &json!({})).unwrap(); + assert!(fonts.as_array().unwrap().iter().any(|f| f["missing"] == true), "{fonts}"); + assert!(s.set_active(0)); + assert!(db.composite_font("CF Back Only").is_some()); +} + +/// A newly opened document measures a missing font without using the previous tab's composite +/// font of the same name. +#[test] +fn opened_document_measures_missing_composite_font_as_missing() { + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Previous Tab")]})).unwrap(); + let id = + s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab", "size": 20, "font": "CF Previous Tab"})).unwrap()["id"].as_u64().unwrap(); + let mut doc = (*s.doc().unwrap().doc).clone(); + doc.composite_fonts.clear(); + if let NodeKind::Text(t) = &mut doc.node_mut(NodeId(id)).unwrap().kind { + t.cached_bounds = None; + } + s.add_document(doc, None); + assert!(s.set_active(0)); + assert!(s.set_active(1)); + let actual = text(&s, id); + let expected = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &actual).bounds; + assert_eq!(actual.cached_bounds, Some(expected)); +} + +/// A loaded composite font cannot replace an installed family used by existing type. +#[test] +fn loaded_composite_font_cannot_take_an_installed_family_name() { + let mut s = session(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab", "size": 20, "font": "Inter"})).unwrap()["id"].as_u64().unwrap(); + let mut doc = (*s.doc().unwrap().doc).clone(); + doc.composite_fonts.push(vectorcraft_doc::CompositeFont::new("Inter", "Source Sans 3", "Regular")); + if let NodeKind::Text(t) = &mut doc.node_mut(NodeId(id)).unwrap().kind { + t.cached_bounds = None; + } + s.add_document(doc, None); + assert!(names(&s).is_empty()); + assert!(vectorcraft_text::FontDb::global().composite_font("Inter").is_none()); + let actual = text(&s, id); + let expected = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &actual).bounds; + assert_eq!(actual.cached_bounds, Some(expected)); +} + +/// A file's composite fonts that Composite Fonts… would refuse (a value out of range, a name used +/// twice) are left out when the file opens. +#[test] +fn unusable_composite_fonts_in_a_file_are_left_out() { + let db = vectorcraft_text::FontDb::global(); + let mut s = session(); + let mut doc = (*s.doc().unwrap().doc).clone(); + let mut wild = vectorcraft_doc::CompositeFont::new("CF Wild", "Source Sans 3", "Regular"); + wild.parts.roman.size = 5000.0; + let good = vectorcraft_doc::CompositeFont::new("CF Kept", "Source Sans 3", "Regular"); + let twice = vectorcraft_doc::CompositeFont::new("cf kept", "Inter", "Regular"); + *doc.composite_fonts = vec![wild, good, twice]; + s.add_document(doc, None); + assert_eq!(names(&s), ["CF Kept"]); + s.execute("document.activate", &json!({"index": 1})).unwrap(); + assert!(db.composite_font("CF Wild").is_none()); + assert_eq!(db.composite_font("CF Kept").unwrap().parts.roman.font, "Source Sans 3"); +} + +/// Export writes the document's composite fonts (all, or those named) to a file; Import reads it: +/// with apply false it only returns them, otherwise they join the document's, replacing a font of +/// the same name, as one undo step. +#[test] +fn composite_fonts_export_and_import() { + let dir = folder("io"); + let path = dir.join("Mixed fonts (日本).vccomposite"); + let path = path.to_str().unwrap(); + let mut s = session(); + assert!(s.execute("type.compositeFonts.export", &json!({"path": path})).is_err(), "nothing to export"); + let mut slash = font("CF IO a/b: \"quoted\" 日本"); + slash["parts"]["kana"] = json!({"font": "Inter", "vScale": 80, "scaleAtCenter": true}); + s.execute("type.compositeFonts.set", &json!({"fonts": [slash, font("CF IO Other")]})).unwrap(); + assert!(s.execute("type.compositeFonts.export", &json!({"path": path, "names": ["CF IO Nope"]})).is_err()); + let r = s + .execute("type.compositeFonts.export", &json!({"path": path, "names": ["cf io a/b: \"quoted\" 日本", "CF IO a/b: \"quoted\" 日本"]})) + .unwrap(); + assert_eq!(r["count"], 1); + let saved = s.doc().unwrap().doc.composite_fonts[0].clone(); + + let mut t = session(); + let mut mine = font("CF IO a/b: \"quoted\" 日本"); + mine["parts"]["roman"]["size"] = json!(90); + t.execute("type.compositeFonts.set", &json!({"fonts": [mine, font("CF IO Kept")]})).unwrap(); + let before = steps(&t); + let read = t.execute("type.compositeFonts.import", &json!({"path": path, "apply": false})).unwrap(); + assert_eq!(read["fonts"].as_array().map(Vec::len), Some(1)); + assert_eq!(steps(&t), before); + t.execute("type.compositeFonts.import", &json!({"path": path})).unwrap(); + assert_eq!(steps(&t), before + 1); + assert_eq!(names(&t), ["CF IO a/b: \"quoted\" 日本", "CF IO Kept"]); + assert_eq!(t.doc().unwrap().doc.composite_fonts[0], saved); + // The same file again changes nothing. + t.execute("type.compositeFonts.import", &json!({"path": path})).unwrap(); + assert_eq!(steps(&t), before + 1); + + // Files that aren't composite fonts, or are too large, are refused. + for (file, bytes) in [ + ("bad.vccomposite", b"not json".to_vec()), + ("empty.vccomposite", br#"{"compositeFonts": []}"#.to_vec()), + ("twice.vccomposite", format!(r#"{{"compositeFonts": [{}, {}]}}"#, font("CF Dup"), font("cf dup")).into_bytes()), + ("large.vccomposite", vec![b' '; (4 << 20) + 1]), + ] { + let p = dir.join(file); + std::fs::write(&p, bytes).unwrap(); + assert!(t.execute("type.compositeFonts.import", &json!({"path": p.to_str().unwrap()})).is_err(), "{file}"); + assert_eq!(steps(&t), before + 1); + } + assert!(t.execute("type.compositeFonts.import", &json!({"path": path, "apply": "no"})).is_err()); + assert!(t.execute("type.compositeFonts.import", &json!({"path": ""})).is_err()); + assert!(t.execute("type.compositeFonts.import", &json!({"path": path, "data": "{}"})).is_err()); + assert!(t.execute("type.compositeFonts.import", &json!({"dataBase64": "%%%"})).is_err()); + + // Without a path, Export gives the file's text, which Import takes as data. + let data = s.execute("type.compositeFonts.export", &json!({"names": ["CF IO Other"]})).unwrap(); + assert_eq!(data["count"], 1); + let mut u = session(); + let r = u.execute("type.compositeFonts.import", &json!({"data": data["data"]})).unwrap(); + assert_eq!(r["imported"], json!(["CF IO Other"])); + assert_eq!(names(&u), ["CF IO Other"]); + // Fonts given are written in place of the document's; bad ones are refused. + let given = s.execute("type.compositeFonts.export", &json!({"fonts": [font("CF IO Given")]})).unwrap(); + assert!(given["data"].as_str().unwrap().contains("CF IO Given")); + assert!(s.execute("type.compositeFonts.export", &json!({"fonts": [font("")]})).is_err()); + assert!(s.execute("type.compositeFonts.export", &json!({"fonts": []})).is_err()); + assert!(s.execute("type.compositeFonts.export", &json!({"path": " "})).is_err()); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 6860df149..407b20a1a 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -743,7 +743,7 @@ fn text_create(s: &mut Session, p: &Value) -> Result { let mut made = super::typecmd::with_overflow(s, id.as_slice(), made); if p.get("font").is_some() || p.get("style").is_some() { let runs = match id.and_then(|i| s.doc().ok()?.doc.node(i)).map(|n| &n.kind) { - Some(NodeKind::Text(t)) => t.runs.iter().map(|r| super::fonts::font_of(&r.style)).collect(), + Some(NodeKind::Text(t)) => t.runs.iter().flat_map(super::fonts::run_fonts).collect(), _ => Default::default(), }; if let Some(w) = super::fonts::set_font_warning(runs) { diff --git a/crates/engine/src/cmd/docmenu.rs b/crates/engine/src/cmd/docmenu.rs index 5b1b4300b..a475bc743 100644 --- a/crates/engine/src/cmd/docmenu.rs +++ b/crates/engine/src/cmd/docmenu.rs @@ -256,7 +256,7 @@ pub fn specs() -> Vec { "Document Color Mode", ["File", "Document Color Mode"], None, - "{mode: \"cmyk\"|\"rgb\", convert?: true (convert every color of the art (opacity masks, effect colors and graphs' series and label paints included), symbols, graph designs, pattern tiles, graphic styles (their effect colors included), brushes, swatches and the paints new art takes through the color settings, together in one undo step (Undo restores their original values; Redo reapplies the conversion); swatch links kept; Gray colors stay Gray, on the black plate), intent?, grays?: \"profile\" (default: RGB grays separate through the CMYK profile like any color, into four-color grays and a rich black) | \"black\" (to CMYK, RGB grays with R = G = B go on the black plate only, K = their gray value)} → {changed: how many colors of the art, symbols, graph designs and pattern tiles changed; the colors of graphic styles and brushes are not counted}", + "{mode: \"cmyk\"|\"rgb\", convert?: true (convert every color of the art (opacity masks, effect colors and graphs' series and label paints included), symbols, graph designs, pattern tiles, graphic styles (their effect colors included), brushes, swatches and the paints new art takes through the color settings, together in one undo step (Undo restores their original values; Redo reapplies the conversion); swatch links kept; Gray colors stay Gray, on the black plate), intent?, grays?: \"profile\" (default: RGB grays separate through the CMYK profile like any color, into light grays in cyan, magenta and yellow only, darker four-color grays and a rich black) | \"black\" (to CMYK, RGB grays with R = G = B go on the black plate only, K = their gray value)} → {changed: how many colors of the art, symbols, graph designs and pattern tiles changed; the colors of graphic styles and brushes are not counted}", has_doc, super::colormgmt::convert_mode ), diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index f6491d757..bf6b673ff 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -794,11 +794,14 @@ fn new_document_from_state(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; let mut doc = Document::clone(chosen.as_ref().unwrap_or(&st.doc)); let (imported_from, import_losses, opened_by) = (st.imported_from.clone(), st.import_losses.clone(), st.opened_by.clone()); - if let Some(path) = st.path.as_deref() { + let base = super::links::LinkBase::of(st); + if base.doc.is_some() { // Files are looked for within the asking caller's roots, else within those of the caller that opened the source. let roots = crate::file_access::current().or_else(|| opened_by.clone()); - crate::file_access::confine(roots.as_ref(), || super::links::with_found_paths(&mut doc, path)); + crate::file_access::confine(roots.as_ref(), || super::links::with_found_paths(&mut doc, base)); } + // The copy's links keep their relative paths, which name places from the same file. + let links_from = base.relative.map(str::to_string); doc.drop_edit_modes(); if doc.pattern_edit.is_some() { super::patterncmds::finish_edit(&mut doc, &mut vectorcraft_doc::Selection::default())?; @@ -815,6 +818,7 @@ fn new_document_from_state(s: &mut Session, p: &Value) -> Result { let st = s.doc_mut()?; st.imported_from = imported_from; st.import_losses = import_losses; + st.links_from = links_from; st.opened_by = st.opened_by.take().or(opened_by); st.mark_unsaved(); Ok(json!({ "index": i, "title": st.title() })) @@ -930,7 +934,10 @@ fn without_inner(doc: &Document, placed: Vec<(NodeId, Option>)>) -> i /// The direct-selection flag and resolved root nodes for Copy and Cut operations. fn copy_cut_targets(s: &Session) -> Result<(bool, Vec)> { let st = s.doc()?; - let in_drawing_tool = matches!(s.tool_id(), "pen" | "curvature"); + // The Pen and the Curvature tool leave the anchor they placed or edited selected: Copy takes + // the whole path then (#2122), unless a selection tool Cmd lent them picked the anchors (#6123). + let picked = s.lent_selection.as_ref().is_some_and(|(uid, sel)| *uid == st.uid && *sel == st.selection); + let in_drawing_tool = matches!(s.tool_id(), "pen" | "curvature") && !picked; let direct = !in_drawing_tool && (!st.selection.anchors.is_empty() || !st.selection.segments.is_empty()); let in_isolated_compound = st.isolation.is_some_and(|iso| st.doc.node(iso).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. }))); let roots = roots_within(&st.doc, st.selection.in_paint_order(&st.doc), |_compound, member| { diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index c453fecd7..13ad41667 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -129,7 +129,9 @@ pub(crate) use access::{drive_share_path, share_path, touched}; pub(crate) use access::{may_read, may_write}; pub use commands::specs; pub use export_names::artboard_file_names; -pub(crate) use export_names::{default_name, merge, unique_file_names, with_black_output, write_or_return}; +pub(crate) use export_names::{ + artboard_file_names_keeping_dots, default_name, merge, unique_file_names_keeping_dots, with_black_output, write_or_return, +}; use export_names::{distinct_names_by, write_encoded}; use formats::{ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, import_name, writable}; pub use formats::{ diff --git a/crates/engine/src/cmd/fileio/export_names.rs b/crates/engine/src/cmd/fileio/export_names.rs index af0e49563..5f2c14bfd 100644 --- a/crates/engine/src/cmd/fileio/export_names.rs +++ b/crates/engine/src/cmd/fileio/export_names.rs @@ -5,19 +5,46 @@ use super::{Encoded, EngineError, Result, Session, Value, json}; /// `-`, `Artboard-N` when unnamed, and `-2`, `-3`… after a name already used (compared without /// case: `Icon` and `icon` are one file on most desktop file systems). pub fn artboard_file_names(doc: &vectorcraft_doc::Document, boards: &[usize]) -> Vec { - unique_file_names(boards.iter().map(|&b| (doc.artboards.get(b).map_or("", |a| a.name.as_str()), format!("Artboard-{}", b + 1)))) + unique_file_names(artboard_names(doc, boards)) +} + +/// [`artboard_file_names`] with the dots of a name kept (`example.artboard`), for Export for Screens, +/// which puts every stem through `safe_file_name` after this (trailing periods, device names). +pub(crate) fn artboard_file_names_keeping_dots(doc: &vectorcraft_doc::Document, boards: &[usize]) -> Vec { + unique_file_names_keeping_dots(artboard_names(doc, boards)) +} + +/// Each of `boards`' name, with the file name to use when it has none. +fn artboard_names<'a>(doc: &'a vectorcraft_doc::Document, boards: &'a [usize]) -> impl Iterator { + boards.iter().map(|&b| (doc.artboards.get(b).map_or("", |a| a.name.as_str()), format!("Artboard-{}", b + 1))) } /// File-name parts for `(name, fallback)` pairs (artboards, assets): the name with unsafe /// characters as `-`, the fallback when it is empty, and `-2`, `-3`… after a name already used /// (compared without case). -pub(crate) fn unique_file_names<'a>(names: impl IntoIterator) -> Vec { +fn unique_file_names<'a>(names: impl IntoIterator) -> Vec { distinct_names(names.into_iter().map(|(name, fallback)| { let base: String = name.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '-' }).collect(); if base.is_empty() { fallback } else { base } })) } +/// [`unique_file_names`] keeping `.` in a name (an artboard called `example.artboard` exports as +/// `example.artboard.png`, #6143). A name of only dots (`.` and `..` name other folders) takes the +/// fallback, and leading dots are dashes (`.draft` would be a hidden file); the caller still runs +/// each stem through `safe_file_name`, which drops trailing periods and guards Windows device names. +pub(crate) fn unique_file_names_keeping_dots<'a>(names: impl IntoIterator) -> Vec { + distinct_names(names.into_iter().map(|(name, fallback)| { + let base: String = name.chars().map(|c| if c.is_alphanumeric() || matches!(c, '-' | '_' | '.') { c } else { '-' }).collect(); + if base.trim_matches('.').is_empty() { + return fallback; + } + // A leading dot would hide the file or folder on Unix-like systems: it is a dash, as before. + let rest = base.trim_start_matches('.'); + format!("{}{rest}", "-".repeat(base.len() - rest.len())) + })) +} + /// `names` with `-2`, `-3`… after a name already used (compared without case). pub(super) fn distinct_names(names: impl IntoIterator) -> Vec { distinct_names_by(names, str::to_lowercase) diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 7ff22689c..697f3c1d7 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -564,13 +564,14 @@ impl ReadFile { /// The first half of [`open_bytes_with`], which needs no session (an app may do it on another /// thread): read and decode the file, read its linked files and lay out its type → what -/// [`open_read`] opens. The linked files are looked for from `path`, where the file was read from, +/// [`open_read`] opens. Type in the file's composite fonts is measured again once the document +/// opens and its composite fonts are registered. The linked files are looked for from `path`, where the file was read from, /// and modified ones are read again when `update_links` (Preferences › File Handling › Update /// Links set to Automatically; see [`crate::cmd::links::resolve`]). pub fn read_for_open(name: &str, bytes: &[u8], path: Option, p: &Value, update_links: bool, low_res_proxy_eps: bool) -> Result { let opts = LoadOptions::from_params("document.open", p)?; let mut loaded = load_with(name, bytes, &opts)?; - let links = crate::cmd::links::resolve(&mut loaded.doc, path.as_deref(), update_links, low_res_proxy_eps); + let links = crate::cmd::links::resolve(&mut loaded.doc, crate::cmd::links::LinkBase::file(path.as_deref()), update_links, low_res_proxy_eps); Session::refresh_text_bounds(&mut loaded.doc); Ok(ReadFile { loaded, opts, path, links }) } @@ -590,7 +591,8 @@ pub fn open_template(s: &mut Session, name: &str, bytes: &[u8], path: Option<&st "only this Affinity file's embedded preview could be read: use File › Open to see it with its warning, or export SVG or PDF from Affinity first", )); } - let links = crate::cmd::links::resolve(&mut loaded.doc, path, s.prefs.update_links == "automatically", s.prefs.low_res_proxy_eps); + let base = crate::cmd::links::LinkBase::file(path); + let links = crate::cmd::links::resolve(&mut loaded.doc, base, s.prefs.update_links == "automatically", s.prefs.low_res_proxy_eps); open_loaded(s, loaded, links, path.map(str::to_string), &LoadOptions::default(), true) } @@ -629,6 +631,7 @@ fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option| distinct_names_by(stems.iter().map(|s| safe_file_name(s)), fold); let named = |pieces: Vec<(String, Arc, Option, Rect)>, fallback: &str| -> Vec { - let stems = safe(unique_file_names(pieces.iter().map(|(n, ..)| (n.as_str(), fallback.to_string())))); + let stems = safe(unique_file_names_keeping_dots(pieces.iter().map(|(n, ..)| (n.as_str(), fallback.to_string())))); pieces .into_iter() .zip(stems) @@ -1261,7 +1261,7 @@ fn items( if boards.is_empty() { return Err(bad(C, "the document has no artboard")); } - let stems = safe(artboard_file_names(doc, &boards)); + let stems = safe(artboard_file_names_keeping_dots(doc, &boards)); Ok(boards .into_iter() .zip(stems) diff --git a/crates/engine/src/cmd/fileio/tests_aiimport.rs b/crates/engine/src/cmd/fileio/tests_aiimport.rs index 26e6bbc3d..8c1722a60 100644 --- a/crates/engine/src/cmd/fileio/tests_aiimport.rs +++ b/crates/engine/src/cmd/fileio/tests_aiimport.rs @@ -720,7 +720,7 @@ fn a_file_whose_pdf_part_cant_be_read_outlines_its_type_when_asked() { assert!(paths > 0, "the outlines are there: {r}"); } -/// An image too large to read from the editing data of a file whose PDF part can't be read is +/// An image whose data is cut short in the editing data of a file whose PDF part can't be read is /// left out, and both notes say so. #[test] fn a_file_whose_pdf_part_cant_be_read_notes_an_image_left_out() { @@ -729,7 +729,7 @@ fn a_file_whose_pdf_part_cant_be_read_notes_an_image_left_out() { let mut s = Session::new(); let e = open(&mut s, name, &file, json!({"editingData": false})).unwrap_err().to_string(); let r = open(&mut s, name, &file, json!({})).unwrap(); - assert_eq!(r["warnings"], json!([unread_pdf_part(&e), "an image too large to read from the editing data was left out"]), "{r}"); + assert_eq!(r["warnings"], json!([unread_pdf_part(&e), "an image whose data is cut short was left out"]), "{r}"); } /// A copied file whose PDF part can't be read pastes the objects of its editing data when asked diff --git a/crates/engine/src/cmd/fileio/tests_pdfedit.rs b/crates/engine/src/cmd/fileio/tests_pdfedit.rs index a38e35e82..4a6f67143 100644 --- a/crates/engine/src/cmd/fileio/tests_pdfedit.rs +++ b/crates/engine/src/cmd/fileio/tests_pdfedit.rs @@ -932,25 +932,31 @@ fn gradients_and_patterns_come_back_from_the_editing_copy() { } let r = s.execute("document.export", &json!({"format": "ai", "aiVersion": 7})).unwrap(); let legacy = load::load_with("art.ai", &b64(&r), &LoadOptions::default()).unwrap(); - // Each gradient the art paints with, by its kind and its stops' places and colors to 5% (version - // 7 writes a gradient stroke as its filled outline, so the objects differ, and a spot color's - // tint through its CMYK equivalent, 2% off here). + // Each gradient the art paints with, by its kind and its stops' places, and its stops' colors + // within 5% (version 7 writes a gradient stroke as its filled outline, so the objects differ), + // except a spot color's tint, which version 7 writes through its CMYK equivalent: 70% of that + // equivalent displays about 7% off the tint of the spot color. let gradients = |doc: &vectorcraft_doc::Document| { - let mut all = std::collections::BTreeSet::new(); + let mut all = std::collections::BTreeMap::new(); doc.walk(|node| { for it in node.appearance.items.iter() { if let vectorcraft_color::Paint::Gradient(g) = it.paint() { - let stops: Vec = g - .gradient - .stops - .iter() - .map(|s| format!("{:.3} {:?}", s.offset, s.color.to_rgb().map(|v| (v * 20.0).round() * 5.0))) - .collect(); - all.insert(format!("{:?} {stops:?}", g.gradient.kind)); + let places: Vec = g.gradient.stops.iter().map(|s| format!("{:.3}", s.offset)).collect(); + let colors: Vec<([f32; 3], bool)> = + g.gradient.stops.iter().map(|s| (s.color.to_rgb(), s.swatch.is_some() && s.tint < 1.0)).collect(); + all.insert(format!("{:?} {places:?}", g.gradient.kind), colors); } } }); all }; - assert_eq!(gradients(&legacy.doc), gradients(&before)); + let (got, want) = (gradients(&legacy.doc), gradients(&before)); + assert_eq!(got.keys().collect::>(), want.keys().collect::>()); + for (key, stops) in &want { + for (i, (rgb, spot)) in stops.iter().enumerate() { + let back = got[key][i].0; + let most = if *spot { 0.1 } else { 0.05 }; + assert!((0..3).all(|c| (back[c] - rgb[c]).abs() < most), "{key} stop {i}: {back:?} for {rgb:?}"); + } + } } diff --git a/crates/engine/src/cmd/fileio/tests_screens.rs b/crates/engine/src/cmd/fileio/tests_screens.rs index 64dd14dcf..b0dcd368e 100644 --- a/crates/engine/src/cmd/fileio/tests_screens.rs +++ b/crates/engine/src/cmd/fileio/tests_screens.rs @@ -148,6 +148,19 @@ fn device_names_and_trailing_periods_follow_windows_naming_rules() { assert_eq!(r["name"], "nul-.zip"); } +/// A dot in an artboard's name stays a dot (`example.artboard.png`, #6143); a name of only dots, or +/// ending in one, still can't name another folder or end in a period, a leading dot is a dash (no +/// hidden `.draft.png`), and `/` and `:` are dashes. +#[test] +fn an_artboard_name_keeps_its_dots() { + let mut s = session(7); + for (index, name) in ["example.artboard", "v1.2.final", "..", "a/b:c", "trail.", ".draft", "..x.y"].iter().enumerate() { + s.execute("artboard.setProps", &json!({"index": index, "name": name})).unwrap(); + } + let r = export(&mut s, json!({"formats": [{"format": "png"}]})); + assert_eq!(names(&r), ["example.artboard.png", "v1.2.final.png", "Artboard-3.png", "a-b-c.png", "trail.png", "-draft.png", "--x.y.png"]); +} + /// `first_file` names the file the export writes first, for the same params: the item's file name, /// a prefix and a suffix with characters Windows does not allow, a range, a Windows device name, /// every kind of folder, presets, size suffixes, a name pattern with its counter and date, an diff --git a/crates/engine/src/cmd/fontfiles.rs b/crates/engine/src/cmd/fontfiles.rs index cadbd6509..309347985 100644 --- a/crates/engine/src/cmd/fontfiles.rs +++ b/crates/engine/src/cmd/fontfiles.rs @@ -67,7 +67,7 @@ pub fn specs() -> Vec { "Missing Fonts", [], None, - "{} → {fonts: [{family, style, status: missing|substitute, resolved: {family, style}}] (sorted by name; at most 1000), count, fontsNextToDocument?} the fonts the active document's type uses, in its layers and its symbols, that aren't available as named: missing (the family is unknown and the fallback family stands in) or substitute (the family is there but not the style; resolved names the stand-in), as text.fonts says. CSS generic families such as sans-serif and names longer than 256 bytes are left out. fontsNextToDocument: the Fonts folder next to the saved document (File › Package writes it), when there is one and a search may start there (not in another app's folders, such as a mail app's attachments)", + "{} → {fonts: [{family, style, status: missing|substitute, resolved: {family, style}}] (sorted by name; at most 1000), count, fontsNextToDocument?} the fonts the active document's type uses, in its layers and its symbols (for type in a composite font, the fonts of the parts its characters are drawn in), that aren't available as named: missing (the family is unknown and the fallback family stands in) or substitute (the family is there but not the style; resolved names the stand-in), as text.fonts says. CSS generic families such as sans-serif and names longer than 256 bytes are left out. fontsNextToDocument: the Fonts folder next to the saved document (File › Package writes it), when there is one and a search may start there (not in another app's folders, such as a mail app's attachments)", has_doc, missing ), @@ -103,8 +103,8 @@ pub struct MissingFont { pub resolved: Option<(String, String)>, } -/// The fonts the type of `d` uses, in its layers and its symbols, that aren't available as named -/// (sorted by family and style, each once whatever version its type names; at most 1000). CSS generic families such as `sans-serif` +/// The fonts the type of `d` uses, in its layers and its symbols (a composite font's parts, those +/// its characters are drawn in, in place of its name), that aren't available as named (sorted by family and style, each once whatever version its type names; at most 1000). CSS generic families such as `sans-serif` /// ([`GENERIC_FAMILIES`]) and names longer than 256 bytes are left out. pub fn missing_fonts(d: &Document) -> Vec { let db = FontDb::global(); diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index efe978628..2a6d1cb0d 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -10,8 +10,9 @@ use vectorcraft_color::Paint; use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind, TextKind, TextObject, TextRun}; use vectorcraft_geom::Rect; +use super::select::on_artboard; use super::typecmd::refresh_bounds; -use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, str_param}; +use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, index, str_param}; pub fn specs() -> Vec { vec![ @@ -20,7 +21,7 @@ pub fn specs() -> Vec { "Fonts in Document", [], None, - "{selectionOnly?} (the type in the art of the symbols in scope included: all of the document's, or those the selected instances show) → [{family, style, runs, objects, missing, status: exact|substitute|missing, resolved: {family, style}, missingGlyphs, version?: the installed version the type names (when it names one)}] sorted by name. A font is found by any of its names (ヒラギノ角ゴシック = Hiragino Sans) among the loaded and installed fonts; substitute: the family is there but not the style (resolved names the stand-in); missing: the family is unknown (the fallback family stands in); missingGlyphs: characters of its text the resolved font lacks (drawn by a fallback font)", + "{selectionOnly?} (the type in the art of the symbols in scope included: all of the document's, or those the selected instances show) → [{family, style, runs, objects, missing, status: exact|substitute|missing, resolved: {family, style}, missingGlyphs, version?: the installed version the type names (when it names one)}] sorted by name. A font is found by any of its names (ヒラギノ角ゴシック = Hiragino Sans) among the loaded and installed fonts; substitute: the family is there but not the style (resolved names the stand-in); missing: the family is unknown (the fallback family stands in); missingGlyphs: characters of its text the resolved font lacks (drawn by a fallback font). For a composite font, status is the worst among the parts its characters are drawn in, and missingGlyphs counts the characters their part's font lacks", has_doc, fonts ), @@ -29,7 +30,7 @@ pub fn specs() -> Vec { "Replace Font", [], None, - "{from: {family, style?}, to: {family (any of its names; stored as the font's own family name), style?}, selectionOnly?} (style omitted: every style of the family / keep the closest style); the type in the art of the symbols in scope changes too (all of the document's, or those the selected instances show, so every instance of them follows); text that is hidden or locked, in a hidden or locked group or layer (or group of a symbol's art), or on a locked artboard keeps its font; when no run changes (text already set in the new font, or none to change), the document and its undo history stay as they are → {runs: the runs changed, skipped?: the text objects left in the font, warning?: text (some text was left in its font, or the style set isn't available: its type is drawn in the family's closest style)}", + "{from: {family, style?}, to: {family (any of its names; stored as the font's own family name, a composite font's as its own name), style?}, selectionOnly?} (style omitted: every style of the family / keep the closest style); the type in the art of the symbols in scope changes too (all of the document's, or those the selected instances show, so every instance of them follows); text that is hidden or locked, in a hidden or locked group or layer (or group of a symbol's art), or on a locked artboard keeps its font; when no run changes (text already set in the new font, or none to change), the document and its undo history stay as they are → {runs: the runs changed, skipped?: the text objects left in the font, warning?: text (some text was left in its font, or the style set isn't available: its type is drawn in the family's closest style)}", has_doc, replace ), @@ -38,7 +39,7 @@ pub fn specs() -> Vec { "Select Text by Font", [], None, - "{family, style?} select the text objects using the font, and the instances of the symbols whose art uses it, that are not hidden or locked, in a hidden or locked group or layer, or on a locked artboard → {count}", + "{family, style?, scope?: \"document\" (default) | {artboards: [index, …] (0-based)} | {layers: [id, …] (layers or sublayers)}} select the text objects using the font, and the instances of the symbols whose art uses it, that are not hidden or locked, in a hidden or locked group or layer, or on a locked artboard; with scope, only those on one of the artboards (as All on Active Artboard takes them: their bounds overlap it or their center is on it) or in one of the layers → {count}", has_doc, select_font ), @@ -207,7 +208,18 @@ pub(super) fn font_label((family, style, version): &UsedFont) -> String { } } -/// The fonts (family, style, version) of the type in the layers and symbols of `d`. +/// The fonts (family, style, version) run `r` is drawn in: its own, or, set in a composite font, +/// the fonts of the parts its characters fall in ([`vectorcraft_text::FontDb::composite_fonts_for`]). +pub(super) fn run_fonts(r: &TextRun) -> Vec { + let parts = if r.inline.is_some() { None } else { vectorcraft_text::FontDb::global().composite_fonts_for(&r.style.font_family, &r.text) }; + match parts { + Some(parts) => parts.into_iter().map(|(family, style)| (family, style, None)).collect(), + None => vec![font_of(&r.style)], + } +} + +/// The fonts (family, style, version) the type in the layers and symbols of `d` is drawn in, a +/// composite font's parts in place of its name ([`run_fonts`]). pub(super) fn used_fonts(d: &Document) -> BTreeSet { art_fonts(d, &[]) } @@ -218,7 +230,7 @@ pub(super) fn art_fonts(d: &Document, art: &[Node]) -> BTreeSet { let mut fonts = BTreeSet::new(); let mut add = |n: &Node| { if let NodeKind::Text(t) = &n.kind { - fonts.extend(t.runs.iter().map(|r| (r.style.font_family.clone(), r.style.font_style.clone(), r.style.font_version.clone()))); + fonts.extend(t.runs.iter().flat_map(run_fonts)); } }; d.walk(&mut add); @@ -343,7 +355,7 @@ impl Printed<'_> { self.overflow.insert(n.id); } for r in runs { - self.fonts.insert((r.style.font_family.clone(), r.style.font_style.clone(), r.style.font_version.clone())); + self.fonts.extend(run_fonts(r)); // Inline graphics draw their symbols' art. if let Some(art) = &r.inline { self.symbol(&art.symbol); @@ -545,14 +557,15 @@ fn font_inventory(s: &Session, selected: Option<&[NodeId]>) -> Result { let symbols = if selected.is_some() { Some(selected_symbols(s, selected)?.0) } else { None }; let mut texts: Vec<&TextObject> = ids.iter().filter_map(|id| text(d, *id)).collect(); texts.extend(symbol_texts(d, symbols.as_ref()).into_iter().map(|(t, _)| t)); - let mut found: BTreeMap = BTreeMap::new(); + // Each font's runs, objects and the text of its runs. + let mut found: BTreeMap)> = BTreeMap::new(); for t in texts { let mut seen = BTreeSet::new(); for r in &t.runs { let font = (r.style.font_family.clone(), r.style.font_style.clone(), r.style.font_version.clone()); let e = found.entry(font.clone()).or_default(); e.0 += 1; - e.2.push_str(&r.text); + e.2.push(&r.text); if seen.insert(font) { e.1 += 1; } @@ -560,14 +573,27 @@ fn font_inventory(s: &Session, selected: Option<&[NodeId]>) -> Result { } let list: Vec = found .into_iter() - .map(|((family, style, version), (runs, objects, chars))| { + .map(|((family, style, version), (runs, objects, texts))| { // The face the type is set in: the version it names when that one is installed. - let resolved = db.resolve(&family, &style).map(|(f, m)| (db.face_version(&family, &style, version.as_deref()).unwrap_or(f), m)); - let status = resolved.as_ref().map(|(_, m)| *m).unwrap_or(vectorcraft_text::FontMatch::Missing); - let mut lacking: Vec = chars.chars().filter(|c| !c.is_whitespace() && !c.is_control()).collect(); - lacking.sort_unstable(); - lacking.dedup(); - lacking.retain(|c| !resolved.as_ref().is_some_and(|(f, _)| f.covers(*c))); + let mut resolved = db.resolve(&family, &style).map(|(f, m)| (db.face_version(&family, &style, version.as_deref()).unwrap_or(f), m)); + let (status, lacking) = match db.composite_coverage(&family, &texts) { + // A composite font: each character is drawn by the font of the part it falls in, and + // the row names the face of the first part that isn't there as named. + Some(c) => { + if c.status != vectorcraft_text::FontMatch::Exact { + resolved = c.face.map(|f| (f, c.status)); + } + (c.status, c.lacking) + } + None => { + let status = resolved.as_ref().map(|(_, m)| *m).unwrap_or(vectorcraft_text::FontMatch::Missing); + let mut lacking: Vec = texts.concat().chars().filter(|c| !c.is_whitespace() && !c.is_control()).collect(); + lacking.sort_unstable(); + lacking.dedup(); + lacking.retain(|c| !resolved.as_ref().is_some_and(|(f, _)| f.covers(*c))); + (status, lacking) + } + }; let mut row = json!({ "family": family, "style": style, "runs": runs, "objects": objects, "missing": status == vectorcraft_text::FontMatch::Missing, @@ -611,10 +637,11 @@ fn replace(s: &mut Session, p: &Value) -> Result { let (ff, fs) = font_param(p, "from", C)?; let (tf, ts) = font_param(p, "to", C)?; let db = vectorcraft_text::FontDb::global(); - // Any name of the family (ヒラギノ角ゴシック) is stored as its own name (Hiragino Sans). - let tf = match db.resolve(&tf, ts.as_deref().unwrap_or("Regular")) { - Some((f, m)) if m != vectorcraft_text::FontMatch::Missing => f.family.clone(), - _ => return Err(bad(C, format!("font family `{tf}` is not available"))), + // Any name of the family (ヒラギノ角ゴシック) is stored as its own name (Hiragino Sans), and a + // composite font as its own name. + let composite = db.composite_font(&tf).is_some(); + let Some(tf) = db.stored_family(&tf, ts.as_deref().unwrap_or("Regular")) else { + return Err(bad(C, format!("font family `{tf}` is not available"))); }; let matches = |st: &vectorcraft_doc::CharStyle| { st.font_family.eq_ignore_ascii_case(&ff) && fs.as_ref().is_none_or(|x| st.font_style.eq_ignore_ascii_case(x)) @@ -625,8 +652,10 @@ fn replace(s: &mut Session, p: &Value) -> Result { if !matches(st) { return None; } - // An explicit style, else the closest the new family has to the old one. - let style = ts.clone().or_else(|| db.face(&tf, &st.font_style).map(|f| f.style.clone())).unwrap_or_else(|| st.font_style.clone()); + // An explicit style, else the closest the new family has to the old one (a composite font's + // parts name their own styles: the run keeps its style). + let closest = || if composite { None } else { db.face(&tf, &st.font_style).map(|f| f.style.clone()) }; + let style = ts.clone().or_else(closest).unwrap_or_else(|| st.font_style.clone()); (st.font_family != tf || st.font_style != style || st.font_version.is_some() || !st.font_axes.is_empty()).then_some(style) }; // Only text the Selection tool can reach changes: not hidden or locked, in a hidden or locked @@ -673,7 +702,7 @@ fn replace(s: &mut Session, p: &Value) -> Result { // Another font: none of the old one's versions or Variable Font settings. r.style.font_version = None; r.style.font_axes.clear(); - set.insert(font_of(&r.style)); + set.extend(run_fonts(r)); changed = true; n += 1; } @@ -689,7 +718,6 @@ fn replace(s: &mut Session, p: &Value) -> Result { } } edit_symbol_texts(d, symbols.as_ref(), &|t, open| open && changes(t), &mut replace_in); - drop(replace_in); Ok(n) })? }; @@ -714,6 +742,7 @@ fn select_font(s: &mut Session, p: &Value) -> Result { let style = str_param(p, "style").map(str::to_string); let ids = scope(s, false, None)?; let d = &s.doc()?.doc; + let within = FontScope::read(d, p)?; // Only text the Selection tool can reach: not hidden or locked, in a hidden or locked group // or layer, or on a locked artboard. let editable = d.editable(); @@ -759,11 +788,65 @@ fn select_font(s: &mut Session, p: &Value) -> Result { }); } drop(editable); + let symbols = d.symbol_boxes(); + hits.retain(|id| within.holds(d, &symbols, *id)); + drop(symbols); let n = hits.len(); s.select(|_, sel| sel.set(hits))?; Ok(json!({ "count": n })) } +/// Where Select Text by Font looks: its `scope`. +enum FontScope { + Document, + /// The artboards' rectangles. + Artboards(Vec), + /// The layers and sublayers, with what they hold. + Layers(BTreeSet), +} + +impl FontScope { + fn read(d: &Document, p: &Value) -> Result { + const C: &str = "select.font"; + let expected = || bad(C, "`scope` must be \"document\", {artboards: [index, …]} or {layers: [id, …]}"); + let scope = match p.get("scope") { + None | Some(Value::Null) => return Ok(Self::Document), + Some(Value::String(v)) if v == "document" => return Ok(Self::Document), + Some(Value::Object(m)) if m.len() == 1 => m, + Some(_) => return Err(expected()), + }; + let list = |key: &str| -> Result>> { + match scope.get(key) { + None => Ok(None), + Some(Value::Array(a)) if !a.is_empty() => Ok(Some(a)), + Some(_) => Err(bad(C, format!("`scope.{key}` must be a non-empty array"))), + } + }; + if let Some(a) = list("artboards")? { + let rect = |v: &Value| v.as_u64().and_then(|i| d.artboards.get(index(i))).map(|b| b.rect); + return a.iter().map(|v| rect(v).ok_or_else(|| bad(C, format!("no such artboard {v}")))).collect::>().map(Self::Artboards); + } + if let Some(a) = list("layers")? { + let layer = |v: &Value| v.as_u64().map(NodeId).filter(|id| d.node(*id).is_some_and(Node::is_layer)); + return a + .iter() + .map(|v| layer(v).ok_or_else(|| bad(C, format!("{v} names no layer or sublayer")))) + .collect::>() + .map(Self::Layers); + } + Err(expected()) + } + + /// Is object `id` in the scope. + fn holds(&self, d: &Document, symbols: &vectorcraft_doc::SymbolBoxes, id: NodeId) -> bool { + match self { + Self::Document => true, + Self::Artboards(rects) => d.node(id).is_some_and(|n| rects.iter().any(|r| on_artboard(n, symbols, *r))), + Self::Layers(layers) => d.ancestry(id).is_some_and(|a| a.iter().any(|i| layers.contains(i))), + } + } +} + fn font_list(_: &mut Session, p: &Value) -> Result { let db = vectorcraft_text::FontDb::global(); match str_param(p, "family") { @@ -912,6 +995,95 @@ mod tests { assert!(s.doc().unwrap().selection.objects.is_empty()); } + /// Select Text by Font with an artboard scope takes the text on the artboards, as All on + /// Active Artboard does: overlapping one, or centered on it, a straddling one included. + #[test] + fn select_font_in_artboards() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + s.execute("artboard.new", &json!({"x": 300, "y": 0, "width": 200, "height": 200})).unwrap(); + s.execute("artboard.new", &json!({"x": 600, "y": 0, "width": 200, "height": 200})).unwrap(); + let text = |s: &mut Session, x: i32| { + NodeId(s.execute("text.create", &json!({"x": x, "y": 50, "text": "Serif", "font": "Source Serif 4"})).unwrap()["id"].as_u64().unwrap()) + }; + let first = text(&mut s, 10); + let straddling = text(&mut s, 180); + let second = text(&mut s, 350); + let third = text(&mut s, 650); + let _off = text(&mut s, 1000); + let select = |s: &mut Session, scope: Value| { + s.execute("select.font", &json!({"family": "Source Serif 4", "scope": scope})).unwrap(); + s.doc().unwrap().selection.objects.clone() + }; + assert_eq!(select(&mut s, json!({"artboards": [0]})), [first, straddling]); + assert_eq!(select(&mut s, json!({"artboards": [1, 2]})), [second, third]); + assert_eq!(select(&mut s, json!("document")).len(), 5); + // The same objects All on Active Artboard takes. + s.execute("select.allOnArtboard", &json!({"artboard": 0})).unwrap(); + assert_eq!(s.doc().unwrap().selection.objects, [first, straddling]); + // Hidden text stays out. + s.execute("select.set", &json!({"ids": [third.0]})).unwrap(); + s.execute("object.hide", &json!({})).unwrap(); + assert_eq!(select(&mut s, json!({"artboards": [1, 2]})), [second]); + for bad in [json!({"artboards": [3]}), json!({"artboards": []}), json!({"artboards": ["x"]}), json!({"pages": [0]}), json!("all"), json!(7)] { + assert!(s.execute("select.font", &json!({"family": "Source Serif 4", "scope": bad})).is_err(), "{bad}"); + } + } + + /// Select Text by Font with a layer scope takes the text in the layers, their sublayers + /// included, and still leaves out hidden and locked text. + #[test] + fn select_font_in_layers() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let text = |s: &mut Session, y: i32| { + NodeId(s.execute("text.create", &json!({"x": 10, "y": y, "text": "Serif", "font": "Source Serif 4"})).unwrap()["id"].as_u64().unwrap()) + }; + let first_layer = s.doc().unwrap().doc.layers[0].id; + let a = text(&mut s, 20); + let second_layer = s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap(); + let b = text(&mut s, 40); + let sub = s.execute("layer.newSublayer", &json!({"parent": second_layer})).unwrap()["id"].as_u64().unwrap(); + s.execute("layer.setCurrent", &json!({"id": sub})).unwrap(); + let in_sub = text(&mut s, 60); + let locked = text(&mut s, 80); + s.execute("select.set", &json!({"ids": [locked.0]})).unwrap(); + s.execute("object.lock", &json!({})).unwrap(); + let select = |s: &mut Session, layers: Value| { + s.execute("select.font", &json!({"family": "Source Serif 4", "scope": {"layers": layers}})).unwrap(); + let mut v = s.doc().unwrap().selection.objects.clone(); + v.sort(); + v + }; + let d = &s.doc().unwrap().doc; + assert_eq!(d.ancestry(in_sub).unwrap()[1], NodeId(sub), "the text is in the sublayer"); + assert_eq!(select(&mut s, json!([first_layer.0])), [a]); + assert_eq!(select(&mut s, json!([second_layer])), [b, in_sub]); + assert_eq!(select(&mut s, json!([sub])), [in_sub]); + assert_eq!(select(&mut s, json!([first_layer.0, sub])), [a, in_sub]); + // Not a layer: an error, the selection as it was. + assert!(s.execute("select.font", &json!({"family": "Source Serif 4", "scope": {"layers": [a.0]}})).is_err()); + assert!(s.execute("select.font", &json!({"family": "Source Serif 4", "scope": {"layers": [u64::MAX]}})).is_err()); + assert_eq!(s.doc().unwrap().selection.objects.len(), 2); + } + + /// A symbol instance whose art uses the font is selected when the instance is in scope. + #[test] + fn select_font_scope_takes_symbol_instances_in_it() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + s.execute("artboard.new", &json!({"x": 300, "y": 0, "width": 200, "height": 200})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Serif", "font": "Source Serif 4"})).unwrap()["id"].clone(); + s.execute("symbol.new", &json!({"name": "Mark", "ids": [id]})).unwrap(); + let instance = s.doc().unwrap().selection.objects[0]; + let select = |s: &mut Session, i: u64| { + s.execute("select.font", &json!({"family": "Source Serif 4", "scope": {"artboards": [i]}})).unwrap()["count"].clone() + }; + assert_eq!(select(&mut s, 0), 1); + assert_eq!(s.doc().unwrap().selection.objects, [instance]); + assert_eq!(select(&mut s, 1), 0); + } + /// Replace Font leaves hidden and locked text in its font, and text inside a hidden or locked /// group or layer, and says so. Text on a visible, unlocked template layer is changed. #[test] diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 6fe0746ac..cf7c9b6b2 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -601,7 +601,7 @@ fn set_current(s: &mut Session, p: &Value) -> Result { fn highlight(s: &mut Session, p: &Value) -> Result { const C: &str = "layer.highlight"; - let mut ids = ids_param(p, "ids").ok_or_else(|| bad(C, "missing ids"))?; + let mut ids = ids_param(p, "ids", C)?.ok_or_else(|| bad(C, "missing ids"))?; ids.truncate(MAX_ROWS); let mode = str_param(p, "mode").unwrap_or("set"); let st = s.doc_mut()?; @@ -740,7 +740,7 @@ enum Place { /// Drag rows in the Layers panel. fn move_rows(s: &mut Session, p: &Value) -> Result { const C: &str = "layer.move"; - let mut ids = ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])).ok_or_else(|| bad(C, "missing ids"))?; + let mut ids = ids_param(p, "ids", C)?.or_else(|| id_param(p, "id").map(|i| vec![i])).ok_or_else(|| bad(C, "missing ids"))?; ids.truncate(MAX_ROWS); let target = id_param(p, "target").ok_or_else(|| bad(C, "missing target"))?; let place = match str_param(p, "place").unwrap_or("inside") { diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index b4f570582..48e2d1793 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -966,7 +966,7 @@ enum Item { /// The selected objects (or `ids`) as a library graphic (its id and name set by the caller). fn graphic_of(s: &Session, p: &Value, cmd: &str) -> Result { let st = s.doc()?; - let ids = match ids_param(p, "ids") { + let ids = match ids_param(p, "ids", cmd)? { Some(ids) => ids, None => st.selection.in_paint_order(&st.doc), }; @@ -1006,7 +1006,7 @@ fn color_of(s: &Session, p: &Value, stroke: bool, cmd: &str) -> Result let given = NewColor { name: color.to_hex(), color, spot: None, tint: 1.0 }; return Ok(spot_color(s, given, swatch, tint)); } - match selected_paint(s, p, stroke)? { + match selected_paint(s, p, stroke, cmd)? { Paint::Solid { color, swatch, tint } => { let swatch = swatch.filter(|n| !n.is_empty()); let name = swatch.clone().unwrap_or_else(|| color.to_hex()); @@ -1029,9 +1029,9 @@ fn spot_color(s: &Session, c: NewColor, swatch: Option, tint: f32) -> Ne /// The first selected object's (or `ids`') fill (`stroke`: stroke) paint (type's: its first /// character's, as the proxy shows it), else the default one. -fn selected_paint(s: &Session, p: &Value, stroke: bool) -> Result { +fn selected_paint(s: &Session, p: &Value, stroke: bool, cmd: &str) -> Result { let st = s.doc()?; - let ids = match ids_param(p, "ids") { + let ids = match ids_param(p, "ids", cmd)? { Some(ids) => ids, None => st.selection.objects.clone(), }; @@ -1071,7 +1071,7 @@ fn gradient_of(s: &Session, p: &Value, stroke: bool, cmd: &str) -> Result (None, selected_paint(s, p, stroke)?), + None => (None, selected_paint(s, p, stroke, cmd)?), }; let Paint::Gradient(mut g) = paint else { return Err(bad(cmd, if stroke { "the selection has no gradient stroke" } else { "the selection has no gradient fill" })); @@ -1094,7 +1094,7 @@ fn pattern_of(s: &Session, p: &Value, stroke: bool, cmd: &str) -> Result name.to_string(), _ => return Err(bad(cmd, format!("no pattern swatch `{name}`"))), }, - None => match selected_paint(s, p, stroke)? { + None => match selected_paint(s, p, stroke, cmd)? { Paint::Pattern { pattern, .. } => pattern, _ => return Err(bad(cmd, if stroke { "the selection has no pattern stroke" } else { "the selection has no pattern fill" })), }, diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 28d8be2ee..22d61723e 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -23,7 +23,7 @@ use std::borrow::Cow; use std::collections::{BTreeMap, BTreeSet}; -use std::path::{Component, Path}; +use std::path::{Component, Path, PathBuf}; use std::sync::Arc; use serde_json::{Value, json}; @@ -219,6 +219,30 @@ struct Found { modified: Option, /// Read when telling whether it changed needed them. bytes: Option>, + /// `path` relative to the folder of [`LinkBase::relative`], when [`locate`] had that file. + relative: Option>, +} + +/// The files a document's links are looked for from: `doc`, whose folder a link's path is followed +/// from and its file is looked for by name in, and `relative`, the file its links' stored relative +/// paths name places from (the file the document was read from). +#[derive(Clone, Copy, Debug, Default)] +pub struct LinkBase<'a> { + pub doc: Option<&'a str>, + pub relative: Option<&'a str>, +} + +impl<'a> LinkBase<'a> { + /// Both the file at `path`. + pub fn file(path: Option<&'a str>) -> Self { + Self { doc: path, relative: path } + } + + /// The open document `st`'s. One with no file of its own looks beside the file it was read from. + pub(crate) fn of(st: &'a crate::DocState) -> Self { + let (path, from) = (st.path.as_deref(), st.links_from.as_deref()); + Self { doc: path.or(from), relative: from.or(path) } + } } impl Found { @@ -237,7 +261,17 @@ impl Found { /// [`Self::link`] with the bytes' hash already taken. fn link_hashed(&self, hash: String, base: &LinkInfo) -> LinkInfo { - LinkInfo { path: self.path.clone(), size: Some(self.size), modified: self.modified, hash: Some(hash), ..base.clone() } + LinkInfo { size: Some(self.size), modified: self.modified, hash: Some(hash), ..self.moved(base) } + } + + /// `base` pointing at this file. A file found away from `base`'s path takes its relative path + /// from where it was found. + fn moved(&self, base: &LinkInfo) -> LinkInfo { + let relative = match &self.relative { + Some(found) if self.path != base.path => found.clone(), + _ => base.relative.clone(), + }; + LinkInfo { path: self.path.clone(), relative, ..base.clone() } } } @@ -255,15 +289,41 @@ pub(crate) fn link_info(path: &str, bytes: &[u8]) -> LinkInfo { /// The file at `path` when there is one. fn found_at(path: String) -> Option { let (size, modified) = file_stamp(&path)?; - Some(Found { path, size, modified, bytes: None }) + Some(Found { path, size, modified, bytes: None, relative: None }) } -/// Where the file of `link` (stored in the document saved at `doc_path`) is: at its path, else at -/// its relative path or by name in the document's folder. Only paths -/// [`fileio::document_path`] follows are looked at. -fn locate(link: &LinkInfo, doc_path: Option<&str>) -> Option { - let stored = [Some(link.path.as_str()), link.relative.as_deref(), Some(link.name())]; - stored.into_iter().flatten().filter_map(|p| fileio::document_path(p, doc_path).ok()).find_map(found_at) +/// Where the file of `link` (of a document looking from `base`) is: at its path, else at its +/// relative path from the folder of [`LinkBase::relative`], then from the document's folder, else +/// by name in the document's folder, then in that of [`LinkBase::relative`]. Only paths +/// [`fileio::document_path`] follows from the document's own file are looked at. +fn locate(link: &LinkInfo, base: LinkBase) -> Option { + let (relative, other) = (link.relative.as_deref(), base.doc != base.relative); + let stored = [ + (Some(link.path.as_str()), base.doc), + (relative, base.relative), + (relative.filter(|_| other), base.doc), + (Some(link.name()), base.doc), + (other.then_some(link.name()), base.relative), + ]; + let mut f = stored.into_iter().filter_map(|(p, from)| follow_from(p?, from, base.doc, fileio::document_path)).find_map(found_at)?; + f.relative = base.relative.map(|from| { + let from = absolute_path(from); + Path::new(&from).parent().and_then(|dir| relative_path(Path::new(&f.path), dir)) + }); + Some(f) +} + +/// The place `stored` names from the file `from`, as `resolve` ([`fileio::document_path`]) follows +/// it, when it also follows that place from the document's own file `doc`: a network share the +/// document is no longer on is refused. +pub(crate) fn follow_from( + stored: &str, + from: Option<&str>, + doc: Option<&str>, + resolve: impl Fn(&str, Option<&str>) -> std::result::Result, +) -> Option { + let path = resolve(stored, from).ok()?; + if from == doc { Some(path) } else { resolve(&path, doc).ok() } } /// Why the path of `link` (stored in the document saved at `doc_path`) isn't followed, when it @@ -272,10 +332,10 @@ fn refused(link: &LinkInfo, doc_path: Option<&str>) -> Option) -> (Status, Option) { - let Some(mut f) = locate(&g.link, doc_path) else { return (Status::Missing, None) }; +fn probe(g: &Group, base: LinkBase) -> (Status, Option) { + let Some(mut f) = locate(&g.link, base) else { return (Status::Missing, None) }; let l = &g.link; if l.size == Some(f.size) && l.modified.is_some() && l.modified == f.modified { return (Status::Ok, Some(f)); @@ -328,9 +388,9 @@ pub(crate) struct LinkedFile { pub bytes: Option>, } -/// Every file the linked images of `d` (saved at `doc_path`) show, in the layers, symbols and +/// Every file the linked images of `d` (looking from `base`) show, in the layers, symbols and /// patterns, once each. -pub(crate) fn linked_files(d: &Document, doc_path: Option<&str>) -> Vec { +pub(crate) fn linked_files(d: &Document, base: LinkBase) -> Vec { let mut seen = std::collections::BTreeSet::new(); groups(d, None) .into_iter() @@ -338,7 +398,7 @@ pub(crate) fn linked_files(d: &Document, doc_path: Option<&str>) -> Vec) -> Vec(d: &'a Document, doc_path: Option<&str>) -> (Cow<'a, Document>, Vec) { +pub(crate) fn full_linked_images<'a>(d: &'a Document, base: LinkBase) -> (Cow<'a, Document>, Vec) { let mut keys = BTreeSet::new(); d.visit_images(|_, im| { if im.link.is_some() && d.images.get(&im.key).is_some_and(ImageBlob::is_proxy) { @@ -363,7 +423,7 @@ pub(crate) fn full_linked_images<'a>(d: &'a Document, doc_path: Option<&str>) -> let all = groups(d, None); for key in keys { let Some(g) = all.iter().find(|g| g.document.is_none() && g.key == key) else { continue }; - let Some(mut f) = locate(&g.link, doc_path) else { + let Some(mut f) = locate(&g.link, base) else { previews.insert(key); continue; }; @@ -398,7 +458,7 @@ pub(crate) fn full_linked_images<'a>(d: &'a Document, doc_path: Option<&str>) -> pub(crate) fn files_to_find(st: &crate::DocState, ids: Option<&[NodeId]>) -> Vec<(LinkInfo, Vec)> { groups(&st.doc, ids) .into_iter() - .filter(|g| !g.ids.is_empty() && (ids.is_some() || probe(g, st.path.as_deref()).0 == Status::Missing)) + .filter(|g| !g.ids.is_empty() && (ids.is_some() || probe(g, LinkBase::of(st)).0 == Status::Missing)) .map(|g| (g.link, g.ids)) .collect() } @@ -847,13 +907,13 @@ impl Resolved { } } -/// The linked files of document `d`, just read from `doc_path`: those found unchanged are read -/// again (their images had the preview), modified ones are read again when `update`, links found -/// away from their path take the new path. A link nothing is known of whose image, or placed +/// The linked files of document `d`, just read and looking from `base`: those found unchanged are +/// read again (their images had the preview), modified ones are read again when `update`, links +/// found away from their path take the new path. A link nothing is known of whose image, or placed /// document, shows a preview (a file another app wrote kept only that) was never read: its file is /// read now, into the object's bounds, which isn't a change to report. A placed document whose /// file's bytes are gone isn't showing a preview. Nothing here is an undo step. -pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_proxy_eps: bool) -> Resolved { +pub fn resolve(d: &mut Document, base: LinkBase, update: bool, low_res_proxy_eps: bool) -> Resolved { let mut out = Resolved::default(); // The image (with its preview) and hash of each file read for a preview here, by path: images // of other keys linking to the same file share them, rather than each reading, decoding and @@ -863,14 +923,14 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_p // away from its path or read again. let mut moved: Vec<(String, String, bool, LinkInfo)> = vec![]; for g in groups(d, None) { - let row = rows(std::iter::once(&g), doc_path); + let row = rows(std::iter::once(&g), base.doc); let shows_preview = match g.document { None => previewing(d, &g.key), Some(_) => d.images.get(&g.key).is_some_and(ImageBlob::is_proxy), }; let unread = g.link.hash.is_none() && g.link.size.is_none() && shows_preview; let eps_preview = keep_eps_preview(low_res_proxy_eps, &g, d); - match probe(&g, doc_path) { + match probe(&g, base) { (Status::Missing, _) => out.missing.extend(row), (Status::Modified, Some(mut f)) if update || unread => { let force = unread.then_some(Preserve::Bounds); @@ -894,7 +954,7 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_p None => { let link = match f.bytes.take() { Some(b) => f.link(&b, &g.link), - None => LinkInfo { path: f.path.clone(), ..g.link.clone() }, + None => f.moved(&g.link), }; if link != g.link { set_links(d, &g.ids, &link); @@ -932,7 +992,7 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_p // Found away from its path, or read again: the link follows. let link = match &bytes { Some(b) => f.link(b, &g.link), - None => LinkInfo { path: f.path.clone(), ..g.link.clone() }, + None => f.moved(&g.link), }; if link != g.link { set_links(d, &g.ids, &link); @@ -1035,42 +1095,68 @@ fn relative_path(target: &Path, base: &Path) -> Option { Some(ups.chain(rest).collect::>>().join("/")) } -/// `d` as saved to `dest`: the links' paths relative to its folder written in; `None` when they -/// are already. Links in the layers, symbols, graph designs, patterns and opacity masks are all set. -pub fn with_relative_paths(d: &Document, dest: &str) -> Option { - let dest = absolute_path(dest); - let dir = Path::new(&dest).parent()?; - let relative = |l: &LinkInfo| relative_path(Path::new(&l.path), dir); - let outdated = |l: &LinkInfo| relative(l) != l.relative; - let mut changed = false; - d.visit_images(|_, im| changed |= im.link.as_ref().is_some_and(outdated)); - d.visit_placed(|_, p| changed |= outdated(&p.link)); - if !changed { - return None; +/// The file `link` (of a document looking from `base`) is written relative to: where [`locate`] +/// finds it, else the place its relative path names from the folder of [`LinkBase::relative`], +/// else its path. +fn link_target(link: &LinkInfo, base: LinkBase) -> PathBuf { + if let Some(f) = locate(link, base) { + return PathBuf::from(f.path); + } + match (base.relative, link.relative.as_deref()) { + (Some(_), Some(rel)) => fileio::document_path(rel, base.relative).map_or_else(|_| PathBuf::from(&link.path), PathBuf::from), + _ => PathBuf::from(&link.path), } - let mut d = d.clone(); - d.update_links_where(outdated, |l| l.relative = relative(l)); - Some(d) } -/// Sets each link of `d`, a copy of the document saved at `doc_path` that has no file of its own, -/// to the path where that document finds its file, so the copy finds the same files. -/// Links in the layers, symbols, graph designs, patterns and opacity masks are all set. -pub(crate) fn with_found_paths(d: &mut Document, doc_path: &str) { - // The new path of each link by what `locate` reads of it: its path, relative path and name. - let mut found: BTreeMap<(String, Option), Option> = BTreeMap::new(); +/// `d` (looking from `base`) as saved to `dest`: the links' paths relative to its folder written +/// in; `None` when they are already. A link whose file is not at its path is written relative to +/// where its file is found, else to the place its relative path names. Links in the layers, +/// symbols, graph designs, patterns and opacity masks are all set. +pub fn with_relative_paths(d: &Document, base: LinkBase, dest: &str) -> Option { + let dest = absolute_path(dest); + let dir = Path::new(&dest).parent()?; + // The relative path of each link by its path and stored relative path, each file looked at once. + let mut wanted: BTreeMap<(String, Option), Option> = BTreeMap::new(); let mut look = |l: &LinkInfo| { - found.entry((l.path.clone(), l.relative.clone())).or_insert_with(|| locate(l, Some(doc_path)).map(|f| f.path)); + wanted.entry((l.path.clone(), l.relative.clone())).or_insert_with(|| relative_path(&link_target(l, base), dir)); }; d.visit_images(|_, im| im.link.iter().for_each(&mut look)); d.visit_placed(|_, p| look(&p.link)); - found.retain(|(path, _), f| f.as_ref().is_some_and(|f| f != path)); + wanted.retain(|(_, stored), rel| rel != stored); + if wanted.is_empty() { + return None; + } + let key = |l: &LinkInfo| (l.path.clone(), l.relative.clone()); + let mut d = d.clone(); + d.update_links_where(|l| wanted.contains_key(&key(l)), |l| l.relative = wanted.get(&key(l)).cloned().flatten()); + Some(d) +} + +/// Sets each link of `d`, a copy of a document looking from `base`, to the path where that +/// document finds its file, and its relative path to it as that document's links have them, so +/// the copy finds the same files. Links in the layers, symbols, graph designs, patterns and opacity +/// masks are all set. +pub(crate) fn with_found_paths(d: &mut Document, base: LinkBase) { + // The new path and relative path of each link by what `locate` reads of it: its path, relative + // path and name. + type PathAndRelative = (String, Option); + let mut found: BTreeMap> = BTreeMap::new(); + let mut look = |l: &LinkInfo| { + found.entry((l.path.clone(), l.relative.clone())).or_insert_with(|| { + let moved = locate(l, base)?.moved(l); + Some((moved.path, moved.relative)) + }); + }; + d.visit_images(|_, im| im.link.iter().for_each(&mut look)); + d.visit_placed(|_, p| look(&p.link)); + found.retain(|(path, _), f| f.as_ref().is_some_and(|(f, _)| f != path)); if found.is_empty() { return; } d.update_links(|l| { - if let Some(Some(f)) = found.get(&(l.path.clone(), l.relative.clone())) { - l.path = f.clone(); + if let Some(Some((path, relative))) = found.get(&(l.path.clone(), l.relative.clone())) { + l.path = path.clone(); + l.relative = relative.clone(); } }); } @@ -1079,15 +1165,15 @@ pub(crate) fn with_found_paths(d: &mut Document, doc_path: &str) { fn check(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; - let ids = ids_param(p, "ids"); + let ids = ids_param(p, "ids", "links.check")?; let (mut missing, mut modified) = (0, 0); let mut links = vec![]; for g in groups(&st.doc, ids.as_deref()).iter().filter(|g| !g.ids.is_empty()) { - let (status, found) = probe(g, st.path.as_deref()); + let (status, found) = probe(g, LinkBase::of(st)); missing += usize::from(status == Status::Missing); modified += usize::from(status == Status::Modified); let mut row = json!({ "name": g.link.name(), "path": g.link.path, "status": status.as_str(), "ids": ids_json(&g.ids) }); - note_refused(&mut row, &g.link, st.path.as_deref()); + note_refused(&mut row, &g.link, LinkBase::of(st).doc); if let Some(f) = found.filter(|f| f.path != g.link.path) { row["found"] = json!(f.path); } @@ -1100,7 +1186,7 @@ fn check(s: &mut Session, p: &Value) -> Result { } fn update(s: &mut Session, p: &Value) -> Result { - update_where(s, ids_param(p, "ids"), None) + update_where(s, ids_param(p, "ids", "links.update")?, None) } /// Update Links in the active document of `s` for objects `ids` (default: the linked files that @@ -1112,7 +1198,7 @@ fn update_where(s: &mut Session, ids: Option>, path: Option<&str>) - let (mut reads, mut missing) = (vec![], vec![]); let asked = ids.is_some() || path.is_some(); for g in groups(&st.doc, ids.as_deref()).into_iter().filter(|g| path.is_none_or(|p| g.document.is_some() && g.link.path == p)) { - match probe(&g, st.path.as_deref()) { + match probe(&g, LinkBase::of(st)) { (Status::Missing, _) | (_, None) => missing.extend(ids_json(&g.ids)), // Unchanged and showing the file: nothing to read unless asked for. (Status::Ok, Some(_)) if !asked && !stale(&st.doc, &g) => {} @@ -1225,14 +1311,14 @@ fn relink(s: &mut Session, p: &Value) -> Result { _ => None, }) }; - let mut ids: Vec = match (&each, ids_param(p, "ids")) { + let mut ids: Vec = match (&each, ids_param(p, "ids", C)?) { (Some(each), _) => each.iter().flat_map(|(ids, _)| ids.iter().copied()).collect(), (None, Some(ids)) => ids, (None, None) => { let selected: Vec = st.selection.objects.iter().copied().filter(|id| image(id).is_some()).collect(); match folder_param { Some(_) if selected.is_empty() => { - groups(&st.doc, None).into_iter().filter(|g| probe(g, st.path.as_deref()).0 == Status::Missing).flat_map(|g| g.ids).collect() + groups(&st.doc, None).into_iter().filter(|g| probe(g, LinkBase::of(st)).0 == Status::Missing).flat_map(|g| g.ids).collect() } _ => selected, } @@ -1368,7 +1454,7 @@ fn missing_in_folder( let Some(dir) = files.first().and_then(|(_, p)| Path::new(p).parent()).map(Path::to_path_buf) else { return }; for g in groups(&st.doc, None).into_iter().filter(|g| !(images_only && g.document.is_some())) { let rest: Vec = g.ids.iter().copied().filter(|id| !given.contains(id)).collect(); - if rest.is_empty() || probe(&g, st.path.as_deref()).0 != Status::Missing { + if rest.is_empty() || probe(&g, LinkBase::of(st)).0 != Status::Missing { continue; } let found = absolute_path(&dir.join(g.link.name()).to_string_lossy()); @@ -1482,7 +1568,7 @@ fn linked_param(st: &crate::DocState, p: &Value, cmd: &str) -> Result { pub(crate) fn statuses(st: &crate::DocState) -> BTreeMap, bool)> { let mut out = BTreeMap::new(); for g in groups(&st.doc, None).iter().filter(|g| !g.ids.is_empty()) { - let (status, found) = probe(g, st.path.as_deref()); + let (status, found) = probe(g, LinkBase::of(st)); let found = found.map(|f| f.path).filter(|p| *p != g.link.path); let preview = stale(&st.doc, g); out.extend(g.ids.iter().map(|id| (*id, (status, found.clone(), preview)))); @@ -1603,7 +1689,7 @@ fn embed(s: &mut Session, p: &Value) -> Result { Some(NodeKind::PlacedDocument(_)) => true, _ => false, }; - let ids = ids_param(p, "ids").unwrap_or_else(|| st.selection.objects.iter().copied().filter(linked).collect()); + let ids = ids_param(p, "ids", C)?.unwrap_or_else(|| st.selection.objects.iter().copied().filter(linked).collect()); if let Some(id) = ids.iter().find(|id| !matches!(kind(id), Some(NodeKind::Image(_) | NodeKind::PlacedDocument(_)))) { return Err(bad(C, format!("object {} is not an image or a placed document", id.0))); } @@ -1625,7 +1711,7 @@ fn embed(s: &mut Session, p: &Value) -> Result { done.extend(g.ids); continue; } - match probe(&g, st.path.as_deref()) { + match probe(&g, LinkBase::of(st)) { (Status::Ok | Status::Modified, Some(mut f)) => match read_image(&mut f, &g.link) { Ok(read) => { done.extend(&g.ids); @@ -1714,8 +1800,9 @@ fn file_fields(st: &crate::DocState, id: NodeId, l: &LinkInfo, out: &mut Value) } fn file_fields_here(st: &crate::DocState, id: NodeId, l: &LinkInfo, out: &mut Value) { - let status = groups(&st.doc, Some(&[id])).first().map_or(Status::Missing, |g| probe(g, st.path.as_deref()).0); - let doc_path = st.path.as_deref(); + let base = LinkBase::of(st); + let status = groups(&st.doc, Some(&[id])).first().map_or(Status::Missing, |g| probe(g, base).0); + let doc_path = base.doc; let path = fileio::document_path(&l.path, doc_path).ok(); let stamp = path.as_deref().and_then(file_stamp); out["status"] = json!(status.as_str()); @@ -1792,7 +1879,8 @@ fn info(s: &mut Session, p: &Value) -> Result { fn placement_options(s: &mut Session, p: &Value) -> Result { const C: &str = "links.placementOptions"; let st = s.doc()?; - let ids = ids_param(p, "ids").unwrap_or_else(|| st.selection.objects.iter().copied().filter(|id| placement_of(&st.doc, *id).is_some()).collect()); + let ids = + ids_param(p, "ids", C)?.unwrap_or_else(|| st.selection.objects.iter().copied().filter(|id| placement_of(&st.doc, *id).is_some()).collect()); if let Some(id) = ids.iter().find(|id| placement_of(&st.doc, **id).is_none()) { return Err(bad(C, format!("object {} is not an image or a linked file", id.0))); } diff --git a/crates/engine/src/cmd/object/transform.rs b/crates/engine/src/cmd/object/transform.rs index 7940fc879..ae45f4d97 100644 --- a/crates/engine/src/cmd/object/transform.rs +++ b/crates/engine/src/cmd/object/transform.rs @@ -114,7 +114,7 @@ fn apply_transform_moving_guides( let c = xf.as_coeffs(); let is_translation = (c[0] - 1.0).abs() < 1e-9 && c[1].abs() < 1e-9 && c[2].abs() < 1e-9 && (c[3] - 1.0).abs() < 1e-9; let delta = guides.unwrap_or(Vec2::new(c[4], c[5])); - let with_guides = objects && ids_param(p, "ids").is_none() && is_translation; + let with_guides = objects && p.get("ids").is_none_or(Value::is_null) && is_translation; let has_guides = with_guides && !s.doc()?.selection.guides.is_empty(); // A transform of ids that name no object, or an identity transform without a copy, moves // nothing: the document and its undo history stay as they are, and Transform Again repeats it. @@ -254,7 +254,7 @@ pub(super) fn transform(s: &mut Session, p: &Value) -> Result { if p.get("ids").is_none() && crate::cmd::brushsym::selected_part(s)?.is_some() { return Ok(json!({ "warning": crate::cmd::brushsym::part_only().to_string() })); } - let (ids, anchors) = match ids_param(p, "ids") { + let (ids, anchors) = match ids_param(p, "ids", "object.transform")? { Some(v) => (outermost(&s.doc()?.doc, v), false), None => (selected_roots_to_transform(s)?, bool_or(p, "anchors", false)), }; diff --git a/crates/engine/src/cmd/opacitymask.rs b/crates/engine/src/cmd/opacitymask.rs index b8b0138c5..9d81576b0 100644 --- a/crates/engine/src/cmd/opacitymask.rs +++ b/crates/engine/src/cmd/opacitymask.rs @@ -210,7 +210,7 @@ fn make(s: &mut Session, p: &Value) -> Result { Some(id) => Err(bad(C, format!("object {} is hidden or locked, or on a locked artboard", id.0))), None => Ok(()), }; - let mut ids = match ids_param(p, "ids") { + let mut ids = match ids_param(p, "ids", C)? { Some(ids) => { if let Some(id) = ids.iter().find(|id| d.node(**id).is_none()) { return Err(EngineError::NoNode(*id)); diff --git a/crates/engine/src/cmd/overprint.rs b/crates/engine/src/cmd/overprint.rs index 7cab59481..7906a68e8 100644 --- a/crates/engine/src/cmd/overprint.rs +++ b/crates/engine/src/cmd/overprint.rs @@ -229,7 +229,7 @@ pub(crate) fn overprint_black(s: &mut Session, p: &Value) -> Result { rich: bool_or(p, "includeCmyBlacks", false), spot: bool_or(p, "includeSpotBlacks", false), }; - let ids = match ids_param(p, "ids") { + let ids = match ids_param(p, "ids", "edit.colors.overprintBlack")? { Some(v) => v, None => selected_roots(s)?, }; diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index e102bf1f7..673ad2cbb 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -13,6 +13,7 @@ use vectorcraft_doc::{Document, LinkInfo}; use serde_json::{Value, json}; use super::fileio::{create_dir, write_file}; +use super::links::LinkBase; use super::{CommandSpec, Result, Session, bad, bool_or, cmd, has_doc, str_param, unique_name}; const C: &str = "file.package"; @@ -187,8 +188,18 @@ impl<'a> Collector<'a> { } /// Collects the files `doc` links. `by_name`: `doc` is a placed `.ai` file's editing data, - /// whose links are not rewritten, so its copy finds them by name beside it. - fn collect(&mut self, doc: &mut Document, source: &str, destination: &str, depth: usize, by_name: bool) -> Result<()> { + /// whose links are not rewritten, so its copy finds them by name beside it. `lookup`: the + /// document whose links (with the same paths as `doc`'s) are looked for, and where from, when + /// not `doc` from `source`. + fn collect( + &mut self, + doc: &mut Document, + source: &str, + destination: &str, + depth: usize, + by_name: bool, + lookup: Option<(&Document, LinkBase)>, + ) -> Result<()> { // The caller checks cycles and depth before descending; active documents have // a destination reserved so back-links can point at their single packaged copy. self.visiting.insert(source.to_string(), destination.to_string()); @@ -210,6 +221,10 @@ impl<'a> Collector<'a> { // The fonts of the type on the PDF and `.ai` pages placed linked. let mut placed_fonts = BTreeSet::new(); let mut placed = BTreeMap::::new(); + let linked_files = |doc: &Document| match lookup { + Some((d, base)) => super::links::linked_files(d, base), + None => super::links::linked_files(doc, LinkBase::file(Some(source))), + }; doc.visit_placed(|_, p| { let page = (p.link.page.unwrap_or(1), p.bounding, p.page_box); let pages = placed.entry(p.link.path.clone()).or_default(); @@ -218,7 +233,7 @@ impl<'a> Collector<'a> { } }); if self.opts.copy_fonts && !self.opts.copy_links && !placed.is_empty() { - for file in super::links::linked_files(doc, Some(source)) { + for file in linked_files(doc) { if let (Some(pages), Some(bytes)) = (placed.get(&file.path), &file.bytes) { placed_fonts.extend(page_fonts(&file.name, bytes, file.found_path.as_deref().unwrap_or(&file.path), pages)); } @@ -226,7 +241,7 @@ impl<'a> Collector<'a> { } if self.opts.copy_links { heading(&mut lines, "LINKED FILES"); - for file in super::links::linked_files(doc, Some(source)) { + for file in linked_files(doc) { let found_path = file.found_path.as_deref().unwrap_or(&file.path); if let Some(ancestor) = self.visiting.get(found_path).cloned() { // The ancestor is already included (possibly the root document). A @@ -272,7 +287,7 @@ impl<'a> Collector<'a> { if depth >= vectorcraft_doc::placed_document::MAX_DEPTH { self.warn(&mut lines, format!("{}: nesting limit reached; copied unchanged without collecting its own links", file.path)); } else { - self.collect(&mut art, found_path, &destination_file, depth + 1, true)?; + self.collect(&mut art, found_path, &destination_file, depth + 1, true, None)?; } } else if self.opts.copy_fonts && let Some(pages) = pages @@ -289,7 +304,7 @@ impl<'a> Collector<'a> { let include_linked = saved.doc.linked_only_images().iter().any(|key| saved.doc.images.get(key).is_some_and(|blob| !blob.is_proxy())); let mut nested = saved.doc; - self.collect(&mut nested, found_path, &destination_file, depth + 1, false)?; + self.collect(&mut nested, found_path, &destination_file, depth + 1, false, None)?; if self.opts.relink { #[cfg(test)] PARTS_READ.with(|c| c.set(c.get() + 1)); @@ -405,6 +420,8 @@ fn package(s: &mut Session, p: &Value) -> Result { pub struct Package { o: Options, doc_path: String, + /// The file the document's links' stored relative paths are taken from. + links_from: String, stem: String, name: String, root: Option, @@ -450,7 +467,8 @@ fn prepare(s: &mut Session, p: &Value) -> Result { let root = folder.map(|f| Path::new(f).join(&name)); let info = if o.report { Some(super::docinfo::report(s, false)?) } else { None }; let doc = s.doc()?.doc.clone(); - Ok(Package { o, doc_path, stem, name, root, info, doc }) + let links_from = s.doc()?.links_from.clone().unwrap_or_else(|| doc_path.clone()); + Ok(Package { o, doc_path, links_from, stem, name, root, info, doc }) } impl Package { @@ -464,18 +482,27 @@ impl Package { /// [`Package::write`]. fn collect_and_write(package: Package, write: &mut dyn FnMut(&str, &[u8]) -> Result<()>) -> Result { - let Package { o, doc_path, stem, name, root, info, doc } = package; - let mut doc = (*doc).clone(); + let Package { o, doc_path, links_from, stem, name, root, info, doc: original } = package; + let mut doc = (*original).clone(); let doc_name = format!("{stem}.{}", vectorcraft_format::EXTENSION); + let base = LinkBase { doc: Some(&doc_path), relative: Some(&links_from) }; + // After a save in another folder, links found away from their paths take the paths where they + // were found, as a copy of the document has them. + let found = (links_from != doc_path).then(|| { + super::links::with_found_paths(&mut doc, base); + doc.clone() + }); + let looked = found.as_ref().unwrap_or(&original); // Prepare original links for the destination *before* rebasing copied links. // Recalculating afterward would overwrite the portable relative paths. if let Some(root) = &root - && let Some(d) = super::links::with_relative_paths(&doc, &root.join(&doc_name).to_string_lossy()) + && let Some(d) = super::links::with_relative_paths(&doc, base, &root.join(&doc_name).to_string_lossy()) { doc = d; } let mut collector = Collector::new(&o, root.clone(), &doc_name); - collector.collect(&mut doc, &doc_path, &doc_name, 0, false)?; + // The files are looked for where the links name them before the rebasing above. + collector.collect(&mut doc, &doc_path, &doc_name, 0, false, Some((looked, base)))?; let mut entries = std::mem::take(&mut collector.entries); entries.insert(0, (doc_name.clone(), vectorcraft_format::save_file(&doc))); let mut taken = std::mem::take(&mut collector.taken); diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 19cdcfbbd..a2cd9a14a 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -950,7 +950,7 @@ fn proxy_recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn( // The proxy recolors its paints, including a colorized image's fill, without changing image pixels or pattern tiles. ItemTarget::Top => { let q = json!({"fill": !stroke, "stroke": stroke, "includeImages": false, "includePatterns": false, "ids": ids.iter().map(|i| i.0).collect::>()}); - (super::colorcmds::recolor(s, &q, label, f)?, None) + (super::colorcmds::recolor(s, &q, cmd, label, f)?, None) } }; let shown_ids = if shown_item.is_some() { ids } else { leaf_targets(s, &ids)? }; diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 052a5c69c..2d31997c4 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -67,7 +67,7 @@ pub fn specs() -> Vec { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\" (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)", + "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)", has_doc, set_format ), @@ -453,6 +453,19 @@ fn spacing_param(p: &Value, key: &str, c: &str) -> Result; 3 } } +/// The list level `listLevel` gives (None without one): a whole number from 1 to +/// [`vectorcraft_doc::text::LIST_LEVELS`], written `2` or `2.0`. +fn list_level_param(p: &Value, cmd: &str) -> Result> { + let key = "listLevel"; + let Some(v) = p.get(key).filter(|v| !v.is_null()) else { return Ok(None) }; + let (min, max) = (1, vectorcraft_doc::text::LIST_LEVELS); + match v.as_f64() { + Some(x) if x.fract() == 0.0 && (min as f64..=max as f64).contains(&x) => Ok(Some(x as u8)), + Some(x) => Err(bad(cmd, format!("`{key}` must be a whole number between {min} and {max} (got {x})"))), + None => Err(bad(cmd, format!("`{key}` must be a whole number"))), + } +} + fn set_format(s: &mut Session, p: &Value) -> Result { const C: &str = "text.setFormat"; let ids = super::typecmd::text_targets(s, p, C)?; @@ -505,6 +518,8 @@ fn set_format(s: &mut Session, p: &Value) -> Result { "list", "bullet", "number", + "listAlign", + "listLevel", "hyphenate", "wordsLongerThan", "afterFirst", @@ -625,6 +640,11 @@ fn set_format(s: &mut Session, p: &Value) -> Result { &["none", "arabic", "upperLetters", "lowerLetters", "upperRoman", "lowerRoman", "twoDigits", "threeDigits"], )? .map(|n| NUMBERS.iter().find(|(k, _)| *k == n).map(|&(_, style)| style)); + // …and `listAlign` where the marker stands before the text, in every list paragraph. + let list_align = super::object::choice(p, C, "listAlign", &["left", "right"])? + .map(|a| if a == "right" { vectorcraft_doc::text::ListAlign::Right } else { vectorcraft_doc::text::ListAlign::Left }); + // …and `listLevel` the level of every list paragraph, 1 to 9. + let list_level = list_level_param(p, C)?; let indents_given = num("leftIndent").is_some() || num("firstLineIndent").is_some(); let spacing = |k: &str| spacing_param(p, k, C); let (word_spacing, letter_spacing, glyph_scaling) = (spacing("wordSpacing")?, spacing("letterSpacing")?, spacing("glyphScaling")?); @@ -721,6 +741,31 @@ fn set_format(s: &mut Session, p: &Value) -> Result { st.font_height_reference = v; } }; + // The list settings of one paragraph: `list`, `bullet`, `number`, `listAlign` and `listLevel`. + let lists = |para: &mut vectorcraft_doc::ParaStyle| { + if list.is_some() || bullet.is_some() || number.is_some() { + let on = list != Some("none") && bullet != Some(None) && number != Some(None); + let had = para.list; + // A numbered list for list "number", or without `list` for a number style. + let numbered = list == Some("number") || (list.is_none() && number.is_some()); + // The list's other settings (the marker's alignment) stay as they were. + para.list = on.then(|| vectorcraft_doc::text::ParaList { + bullet: bullet.flatten().or(had.map(|l| l.bullet)).unwrap_or_default(), + number: numbered.then(|| number.flatten().or(had.and_then(|l| l.number)).unwrap_or_default()), + ..had.unwrap_or_default() + }); + // A paragraph that joins a list with no left or first-line indent gets a + // hanging indent, unless the call sets an indent. + if on && had.is_none() && !indents_given && para.left_indent == 0.0 && para.first_line_indent == 0.0 { + para.left_indent = vectorcraft_doc::text::LIST_HANGING_INDENT; + para.first_line_indent = -vectorcraft_doc::text::LIST_HANGING_INDENT; + } + } + if let Some(l) = para.list.as_mut() { + l.align = list_align.unwrap_or(l.align); + l.level = list_level.unwrap_or(l.level); + } + }; s.edit("Character", |d, _| { for id in &ids { let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; @@ -746,22 +791,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { if let Some(v) = num("spaceAfter") { para.space_after = v.clamp(-MAX_PARA, MAX_PARA); } - if list.is_some() || bullet.is_some() || number.is_some() { - let on = list != Some("none") && bullet != Some(None) && number != Some(None); - let had = para.list; - // A numbered list for list "number", or without `list` for a number style. - let numbered = list == Some("number") || (list.is_none() && number.is_some()); - para.list = on.then(|| vectorcraft_doc::text::ParaList { - bullet: bullet.flatten().or(had.map(|l| l.bullet)).unwrap_or_default(), - number: numbered.then(|| number.flatten().or(had.and_then(|l| l.number)).unwrap_or_default()), - }); - // A paragraph that joins a list with no left or first-line indent gets a - // hanging indent, unless the call sets an indent. - if on && had.is_none() && !indents_given && para.left_indent == 0.0 && para.first_line_indent == 0.0 { - para.left_indent = vectorcraft_doc::text::LIST_HANGING_INDENT; - para.first_line_indent = -vectorcraft_doc::text::LIST_HANGING_INDENT; - } - } + lists(para); if let Some(v) = flag("hyphenate") { para.hyphenate = v; } @@ -853,14 +883,14 @@ fn set_format(s: &mut Session, p: &Value) -> Result { /// The part of a story paragraph in one frame, as [`convert_list_to_text`] reads it: the frame, /// the frame's paragraph, its bytes there, its list, the marker the frame's layout draws for it -/// and the numbered paragraphs the frame counts before it. +/// and the numbered paragraphs the frame counts before it at each list level. struct ParagraphPart { frame: usize, index: usize, bytes: std::ops::Range, list: Option, marker: Option, - count: u32, + count: vectorcraft_doc::text::ListCounts, } /// A paragraph of a story: its parts in thread order (a later frame's first paragraph goes on @@ -902,7 +932,7 @@ fn marker_extent( lay: &vectorcraft_text::TextLayout, index: usize, bytes: std::ops::Range, - count: u32, + count: vectorcraft_doc::text::ListCounts, ) -> (f64, f64) { let width = |lay: &vectorcraft_text::TextLayout, start: usize| { lay.markers.iter().find(|m| lay.glyphs.get(m.glyphs.start).is_some_and(|g| g.byte == start)).map(|m| { @@ -916,7 +946,7 @@ fn marker_extent( probe.runs = vectorcraft_text::edit::slice_runs(&t.runs, bytes.start, bytes.end); probe.ruby.clear(); probe.set_all_paras(t.para_at(index).clone()); - probe.list_count = count; + probe.set_list_counts(count); probe.first_para_continues = false; width(&vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &probe), 0).unwrap_or((0.0, 0.0)) }) @@ -1068,7 +1098,7 @@ fn convert_list_to_text(s: &mut Session, p: &Value) -> Result { let plains: Vec = frames.iter().map(|t| t.plain_text()).collect(); for (f, ((id, t), plain)) in story.iter().zip(&frames).zip(&plains).enumerate() { let named = if ids.contains(id) { range.map_or(0..t.paragraph_count(), |r| r.paras(t)) } else { 0..0 }; - let mut count = t.list_count; + let mut count = t.list_counts(); let mut start = 0; for (k, piece) in plain.split('\n').enumerate() { let bytes = start..start + piece.len(); @@ -1130,8 +1160,9 @@ fn convert_list_to_text(s: &mut Session, p: &Value) -> Result { // the left indent, nor one past it in a paragraph with no tab of its own. let style = t.para_at(para.index); let (width, gap) = marker_extent(t, lay, para.index, para.bytes.clone(), para.count); - let pen = style.left_indent + style.first_line_indent + width; - let indent = style.left_indent; + // A list level past the first sets the paragraph further in than its left indent. + let indent = style.laid_out_left_indent(); + let pen = indent + style.first_line_indent + width; let before = style.tabs.iter().any(|tab| tab.position > pen + 1e-6 && tab.position < indent - 1e-6); let end = pen + gap; let needed = |x: f64| !plain_stop_at(&style.tabs, x) || style.tabs.iter().any(|tab| aligned_between(tab, pen, x)); @@ -1163,6 +1194,8 @@ fn convert_list_to_text(s: &mut Session, p: &Value) -> Result { // A paragraph in several frames is changed in its main part and in its empty parts: // re-flowing the thread gives the other parts with text the main part's style. let Some(mut style) = frames.get(para.frame).map(|t| t.para_at(para.index).clone()) else { continue }; + // The paragraph keeps the indent its list level set it at. + style.left_indent = style.laid_out_left_indent(); style.list = None; if direction.is_some() { style.direction = direction; diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index fb81e1e49..4cb6880f0 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -597,7 +597,7 @@ pub(super) fn join(s: &mut Session, p: &Value) -> Result { }, }; - if let Some(ids) = ids_param(p, "ids") { + if let Some(ids) = ids_param(p, "ids", C)? { if ends.is_some() && ids.len() > 2 { return Err(bad(C, "`ends` applies to joining exactly two paths")); } @@ -1101,7 +1101,7 @@ fn average_join_ends(s: &Session, p: &Value, c: &str) -> Result let doc = &st.doc; let subpath = |id: NodeId, si: usize| doc.node(id)?.path_data()?.subpaths.get(si).filter(|sp| !sp.closed && !sp.anchors.is_empty()); let last = |sp: &SubPath| sp.anchors.len().saturating_sub(1); - let ids = ids_param(p, "ids"); + let ids = ids_param(p, "ids", c)?; let ends = if ids.is_none() && !st.selection.anchors.is_empty() { let picked: Vec = st.selection.anchors.iter().flat_map(|(&id, set)| set.iter().map(move |&(si, ai)| (id, si, ai))).collect(); match picked.as_slice() { diff --git a/crates/engine/src/cmd/patterncmds.rs b/crates/engine/src/cmd/patterncmds.rs index 1e7f88d2b..55c4390c2 100644 --- a/crates/engine/src/cmd/patterncmds.rs +++ b/crates/engine/src/cmd/patterncmds.rs @@ -219,8 +219,8 @@ fn ids_json(ids: &[NodeId]) -> Value { json!({ "ids": ids.iter().map(|i| i.0).collect::>() }) } -fn roots(s: &Session, p: &Value) -> Result> { - match ids_param(p, "ids") { +fn roots(s: &Session, p: &Value, cmd: &str) -> Result> { + match ids_param(p, "ids", cmd)? { Some(ids) => { let st = s.doc()?; let mut sel = Selection::default(); @@ -306,7 +306,7 @@ fn pattern_make(s: &mut Session, p: &Value) -> Result { if editing(s).is_some() { return Err(bad(C, "already in pattern editing mode")); } - let roots = roots(s, p)?; + let roots = roots(s, p, C)?; if roots.is_empty() { return Err(bad(C, "select the art for the pattern tile")); } @@ -665,9 +665,9 @@ fn pattern_transform(s: &mut Session, p: &Value) -> Result { // ---------- Repeat ---------- /// Make a repeat from the selection, or switch the selected repeat's kind. -fn make_repeat(s: &mut Session, p: &Value, label: &str, make: impl Fn(Vec>) -> RepeatSpec) -> Result { +fn make_repeat(s: &mut Session, p: &Value, cmd: &str, label: &str, make: impl Fn(Vec>) -> RepeatSpec) -> Result { let existing = selected_repeats(s); - if ids_param(p, "ids").is_none() + if ids_param(p, "ids", cmd)?.is_none() && let [r] = existing.as_slice() { let r = *r; @@ -680,7 +680,7 @@ fn make_repeat(s: &mut Session, p: &Value, label: &str, make: impl Fn(Vec Result { return Err(bad("object.repeat.radial", "instances must be 1..1000")); } let radius = p.get("radius").and_then(Value::as_f64).filter(|r| r.is_finite() && *r >= 0.0); - make_repeat(s, p, "Radial Repeat", |src| RepeatSpec::radial(src, n as u32, radius)) + make_repeat(s, p, "object.repeat.radial", "Radial Repeat", |src| RepeatSpec::radial(src, n as u32, radius)) } fn repeat_grid(s: &mut Session, p: &Value) -> Result { let (hs, vs) = (p.get("hSpacing").and_then(Value::as_f64), p.get("vSpacing").and_then(Value::as_f64)); let rows = p.get("rows").and_then(Value::as_u64).map(|v| v.clamp(1, 500) as u32); let cols = p.get("cols").and_then(Value::as_u64).map(|v| v.clamp(1, 500) as u32); - make_repeat(s, p, "Grid Repeat", |src| { + make_repeat(s, p, "object.repeat.grid", "Grid Repeat", |src| { let b = vectorcraft_doc::live::nodes_bounds(&src).unwrap_or(Rect::new(0.0, 0.0, 10.0, 10.0)); let mut r = RepeatSpec::grid(src, hs.unwrap_or(b.width() / 4.0), vs.unwrap_or(b.height() / 4.0)); if let RepeatKind::Grid { rows: rr, cols: cc, .. } = &mut r.kind { @@ -720,7 +720,7 @@ fn repeat_grid(s: &mut Session, p: &Value) -> Result { fn repeat_mirror(s: &mut Session, p: &Value) -> Result { let angle = f64_or(p, "angle", 90.0); let offset = f64_or(p, "offset", 10.0); - make_repeat(s, p, "Mirror Repeat", |src| RepeatSpec::mirror(src, angle, offset)) + make_repeat(s, p, "object.repeat.mirror", "Mirror Repeat", |src| RepeatSpec::mirror(src, angle, offset)) } fn repeat_release(s: &mut Session, _: &Value) -> Result { diff --git a/crates/engine/src/cmd/patternlib.rs b/crates/engine/src/cmd/patternlib.rs index b3e83ff22..3e15c563f 100644 --- a/crates/engine/src/cmd/patternlib.rs +++ b/crates/engine/src/cmd/patternlib.rs @@ -96,7 +96,9 @@ pub(crate) fn import(d: &mut Document, lib: &Document, name: &str) -> String { pub(crate) fn existing_pattern(d: &Document, lib: &Document, name: &str) -> Option { let def = lib.pattern(name)?; let bare = |p: &PatternDef| PatternDef { name: String::new(), art: vec![], ..p.clone() }; - let copy = |n: &str| n == name || n.strip_prefix(name).and_then(|r| r.strip_prefix(' ')).is_some_and(|r| r.parse::().is_ok()); + let copy = |n: &str| { + n == name || n.strip_prefix(name).and_then(|r| r.strip_prefix(' ')).is_some_and(|r| !r.is_empty() && r.bytes().all(|b| b.is_ascii_digit())) + }; let mut found: Vec<&PatternDef> = d.patterns.iter().filter(|p| copy(&p.name) && bare(p) == bare(def) && p.art == def.art).collect(); found.sort_by_key(|p| p.name != name); found.first().map(|p| p.name.clone()) @@ -150,3 +152,19 @@ fn geometric_patterns(d: &mut Document) { add(d, "Crosses", 10.0, 10.0, TileType::Grid, vec![rect(4.0, 1.5, 6.0, 8.5), rect(1.5, 4.0, 8.5, 6.0)]); add(d, "Zigzag", 10.0, 8.0, TileType::Grid, vec![polygon(&[(0.0, 5.0), (5.0, 1.0), (10.0, 5.0), (10.0, 7.0), (5.0, 3.0), (0.0, 7.0)])]); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plus_signed_suffix_is_not_a_copy_number() { + let mut lib = Document::new(100.0, 100.0); + lib.patterns.push(PatternDef::new("Dots", vec![])); + for (other, expected) in [("Dots +2", None), ("Dots -2", None), ("Dots 2a", None), ("Dots ", None), ("Dots 2", Some("Dots 2"))] { + let mut d = Document::new(100.0, 100.0); + d.patterns.push(PatternDef::new(other, vec![])); + assert_eq!(existing_pattern(&d, &lib, "Dots").as_deref(), expected, "{other}"); + } + } +} diff --git a/crates/engine/src/cmd/place.rs b/crates/engine/src/cmd/place.rs index 40d176ab8..db9bbf67d 100644 --- a/crates/engine/src/cmd/place.rs +++ b/crates/engine/src/cmd/place.rs @@ -254,7 +254,7 @@ fn load(p: &Value, cmd: &str, board: Option, link_pdf: bool) -> Result (Cow<'_, Document>, Vec) { - let (d, mut warnings) = super::super::links::full_linked_images(d, None); + let (d, mut warnings) = super::super::links::full_linked_images(d, Default::default()); let doc = match &d { Cow::Borrowed(doc) => doc, Cow::Owned(doc) => doc, diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index 75dd5d497..28a9d4dbb 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -24,7 +24,10 @@ //! `vc_cancelled` and `vc_log` ([`vectorcraft_plugins::host`]): run inside a //! [`vectorcraft_doc::cancel::scope`] (the app's heavy filters and background exports), its //! progress goes to the scope's sink and it sees the scope's Cancel; its log lines go to the log -//! under its id. +//! under its id. One that lists `settings` or `cache` may keep settings and a cache of its own +//! (`vc_settings_*`, `vc_cache_*`), in the plug-in store under its id ([`crate::store`]): +//! `plugin.clearCache` empties the cache, `plugin.list` and `plugin.info` give its size +//! (`cacheBytes`), replacing the plug-in keeps both and `plugin.remove` deletes them. use std::sync::{Arc, Mutex}; @@ -141,7 +144,7 @@ impl FilterRun { let st = s.doc()?; // While an object of a dynamic symbol instance's art is selected, the selection is refused, // as `selected_roots` refuses it. - let selected = match ids_param(p, "ids") { + let selected = match ids_param(p, "ids", C)? { Some(ids) => ids, None => { super::edit::refuse_symbol_part(s)?; @@ -340,14 +343,28 @@ fn grant_param(c: &str, p: &Value) -> Result>> { Ok(Some(out)) } +/// Would installing `plugin` take another module's place: one installed with its id and other +/// bytes, or, with none installed, the settings and cache the plug-in store keeps for its id? +fn takes_over(s: &Session, plugin: &Plugin) -> bool { + match registry::get(plugin.id()) { + Some(installed) => installed.hash() != plugin.hash(), + None => s.has_plugin_storage(plugin.id()), + } +} + /// `plugin.install`: loads and checks the module, refuses one that asks for a heavy budget unless -/// `allowHeavy` says the user agreed, and one that lists permissions the user hasn't granted this -/// very module unless `grant` lists them all, and one whose SHA-256 isn't the `hash` given; records -/// the grant with the module's hash, keeps it for the next start and installs it → its -/// `plugin.list` entry. A module that lists no permissions ends any grant held for its id. +/// `allowHeavy` says the user agreed, one that lists permissions the user hasn't granted this very +/// module unless `grant` lists them all, one that would take another module's place ([`takes_over`]) +/// unless `replace` is true, and one whose SHA-256 isn't the `hash` given; records the grant with +/// the module's hash, keeps it for the next start and installs it → its `plugin.list` entry. A +/// module that lists no permissions ends any grant held for its id. fn install(s: &mut Session, p: &Value) -> Result { const C: &str = "plugin.install"; - let (replace, allow_heavy) = (switch_or(p, "replace", true, C)?, switch_or(p, "allowHeavy", false, C)?); + let replace = match p.get("replace") { + None | Some(Value::Null) => None, + Some(_) => Some(switch_or(p, "replace", false, C)?), + }; + let allow_heavy = switch_or(p, "allowHeavy", false, C)?; let grant = grant_param(C, p)?; let (plugin, bytes) = load_given(C, p)?; // `hash` pins the module the user was shown: one with other bytes is refused. @@ -356,9 +373,21 @@ fn install(s: &mut Session, p: &Value) -> Result { { return Err(bad(C, "the module doesn't have the SHA-256 `hash` gives: nothing was installed")); } - if !replace && registry::get(plugin.id()).is_some() { + if replace == Some(false) && registry::get(plugin.id()).is_some() { return Err(bad(C, format!("a plug-in with id {:?} is already installed", plugin.id()))); } + // Another module under the id gets the settings and cache kept for it: whoever installs it asks + // the user first. + if replace != Some(true) && takes_over(s, &plugin) { + let name = permissions::display_text(&plugin.manifest().name, permissions::MAX_SHOWN_NAME); + return Err(bad( + C, + format!( + "“{name}” is another module than the plug-in with id {:?} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees", + plugin.id() + ), + )); + } // A heavy plug-in is installed once the user agrees: whoever installs it asks them first. if plugin.manifest().budget == Budget::Heavy && !allow_heavy { let (minutes, times) = vectorcraft_plugins::heavy_terms(); @@ -379,21 +408,31 @@ fn install(s: &mut Session, p: &Value) -> Result { let asked = permissions::names(&wanted).join(", "); return Err(bad(C, format!("“{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees"))); } + // Installed plug-ins stay installed after a restart (#2541). The module is written first and its + // grant only once it is there; when the grant can't be written, the store gets the module it + // held back. A failed write of that module is only logged, and the grant then doesn't cover + // the new module. A stored module that can't be read back isn't replaced by one that needs a + // grant. + let previous = s.stored_plugin_bytes(plugin.id()); + let mut stored = (previous.is_ok() || !m.asks_permissions()) && s.store_plugin(plugin.id(), &bytes); if m.asks_permissions() { let g = Grant { id: m.id.clone(), hash: plugin.hash().to_string(), permissions: wanted }; registry::grant(g.clone()); - s.store_grant(&m.id, Some(g)); + if stored && !s.store_grant(&m.id, Some(g)) { + s.put_back_plugin(plugin.id(), previous.ok().flatten().as_deref()); + stored = false; + } } else { // This module takes the id over, so nothing the user approved for the id applies any more. registry::revoke(&m.id); + // When the write failed, the old stored module stays in the folder and needs its grant at + // the next launch. + if stored { + s.store_grant(&m.id, None); + } } - // Installed plug-ins stay installed after a restart (#2541). - let stored = s.store_plugin(plugin.id(), &bytes); - // When the write fails, the old stored module stays in the folder and needs its grant at the - // next launch. - if stored && !m.asks_permissions() { - s.store_grant(&m.id, None); - } + // It gets the settings and cache kept for its id. + s.attach_plugin_storage(plugin.id()); // Installing a disabled plug-in again (an updated version) turns it on for the next launch too, // also when it is disabled by its stored file alone (it stopped a launch while loading). When // the new version could not replace the stored file, the old one stays disabled. @@ -408,15 +447,17 @@ fn install(s: &mut Session, p: &Value) -> Result { if changed { s.store_disabled_plugins(); } - Ok(describe(®istry::install(plugin))) + Ok(describe_installed(®istry::install(plugin))) } -/// `plugin.inspect`: what installing the module would install, without installing it, and whether -/// that needs the user to grant its permissions first (`needsApproval`). +/// `plugin.inspect`: what installing the module would install, without installing it, whether that +/// needs the user to grant its permissions first (`needsApproval`) and whether it would take +/// another module's place (`needsReplace`). fn inspect(s: &mut Session, p: &Value) -> Result { load_given("plugin.inspect", p).map(|(plugin, _)| { let mut v = describe(&plugin); v["needsApproval"] = json!(!s.plugin_approved(&plugin)); + v["needsReplace"] = json!(takes_over(s, &plugin)); v }) } @@ -487,6 +528,9 @@ fn load_folder(s: &Session, folder: &str) -> Value { }; match registry::load_folder_where(std::path::Path::new(folder), allow, s.plugin_approver()) { Ok(r) => { + for id in &r.loaded { + s.attach_plugin_storage(id); + } if r.disabled_changed { s.store_disabled_plugins(); } @@ -518,11 +562,18 @@ fn reload(s: &mut Session, p: &Value) -> Result { Ok(report) } +/// [`describe`] for an installed plug-in, with the bytes its cache holds (`cacheBytes`). +fn describe_installed(p: &Plugin) -> Value { + let mut v = describe(p); + v["cacheBytes"] = json!(registry::storage(p.id()).map_or(0, |st| st.cache_bytes())); + v +} + fn list(_: &mut Session, _: &Value) -> Result { let folder = FOLDER.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|(_, _, r)| r.clone()); let disabled: Vec = safe_mode::disabled().iter().map(safe_mode::Item::to_json).collect(); Ok( - json!({"plugins": registry::list().iter().map(|p| describe(p)).collect::>(), "folder": folder, "safeMode": safe_mode::active(), "disabled": disabled}), + json!({"plugins": registry::list().iter().map(|p| describe_installed(p)).collect::>(), "folder": folder, "safeMode": safe_mode::active(), "disabled": disabled}), ) } @@ -577,7 +628,7 @@ fn set_enabled(s: &mut Session, p: &Value) -> Result { fn info(_: &mut Session, p: &Value) -> Result { let plugin = plugin_by_id("plugin.info", p)?; - let mut v = describe(&plugin); + let mut v = describe_installed(&plugin); v["manifest"] = serde_json::to_value(plugin.manifest()).unwrap_or(Value::Null); v["defaults"] = Value::Object(plugin.manifest().defaults()); v["lastError"] = json!(effect::last_error(plugin.id())); @@ -586,15 +637,48 @@ fn info(_: &mut Session, p: &Value) -> Result { fn remove(s: &mut Session, p: &Value) -> Result { let id = str_param(p, "id").ok_or_else(|| bad("plugin.remove", "missing `id`"))?; + let installed = registry::get(id).is_some(); + // Settings and cache the store keeps for an id that isn't installed are deleted too. + let stored = !installed && vectorcraft_plugins::manifest::valid_id(id) && s.has_plugin_storage(id); + // Its settings and cache go first: when they can't be moved aside, the plug-in stays installed, + // as it was, and a second `plugin.remove` tries again. + if (installed || stored) + && let Err(e) = s.remove_plugin_storage(id) + { + if installed { + s.attach_plugin_storage(id); + } + return Err(EngineError::Other(e.to_string())); + } // Installed again, it asks again. registry::revoke(id); s.store_grant(id, None); s.unstore_plugin(id); - if registry::remove(id) { Ok(json!({"removed": id})) } else { Err(err(vectorcraft_plugins::Error::NotFound(id.into()))) } + if !stored && (!installed || !registry::remove(id)) { + return Err(err(vectorcraft_plugins::Error::NotFound(id.into()))); + } + Ok(json!({"removed": id})) +} + +/// `plugin.clearCache`: empties the cache of plug-in `id`, else of every installed plug-in → +/// `{cleared: [id], bytesFreed}`. Settings stay. +fn clear_cache(_: &mut Session, p: &Value) -> Result { + let plugins = match p.get("id").filter(|v| !v.is_null()) { + Some(_) => vec![plugin_by_id("plugin.clearCache", p)?], + None => registry::list(), + }; + let (mut cleared, mut freed) = (Vec::new(), 0_u64); + for plugin in plugins { + if let Some(st) = registry::storage(plugin.id()) { + freed += st.clear_cache().map_err(|e| EngineError::Other(e.to_string()))?; + } + cleared.push(plugin.id().to_string()); + } + Ok(json!({"cleared": cleared, "bytesFreed": freed})) } /// This module's commands: `plugin.list`, `.info`, `.run`, `.install`, `.inspect`, `.remove`, -/// `.reload`, `.safeMode` and `.setEnabled`. +/// `.reload`, `.clearCache`, `.safeMode` and `.setEnabled`. pub fn specs() -> Vec { vec![ cmd!( @@ -602,7 +686,7 @@ pub fn specs() -> Vec { "List Plug-ins", [], None, - "{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.), format? and extensions? (an exporter's format id, plugin., for document.export, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null, safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}", + "{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.), format? and extensions? (an exporter's format id, plugin., for document.export, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines; settings: it may keep settings of its own; cache: it may keep a cache of its own), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2), cacheBytes (the bytes its cache holds; see plugin.clearCache)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null, safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}", always, list ), @@ -629,7 +713,7 @@ pub fn specs() -> Vec { "Install Plug-in", [], None, - "{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true: a plug-in with the same id is replaced), name?: file name (with dataBase64), allowHeavy?: bool (true once the user agreed to a heavy budget: a filter whose manifest asks for one, budget \"heavy\" in plugin.inspect, installs only with it), grant?: [permission] (the permissions the user granted: a plug-in whose manifest lists permissions, needsApproval in plugin.inspect, installs only when grant holds every one of them; the grant is recorded with the module's hash and covers only that module, so a module with other bytes needs a new grant even when it lists the same permissions; installing a module that lists no permissions forgets the grant held for its id; with a plug-in store attached, only the grants kept there count), hash?: string (the module's SHA-256 as plugin.inspect gave it: a module with other bytes is refused)} validate and install a WebAssembly plug-in for this session → its plugin.list entry", + "{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true once the user agreed: a module with other bytes than the plug-in installed with its id, or with none installed, one whose id has settings or a cache in the plug-in store, installs only with it, takes that plug-in's place and gets its settings and cache; false: one whose id is installed is refused; left out, the same module installs again), name?: file name (with dataBase64), allowHeavy?: bool (true once the user agreed to a heavy budget: a filter whose manifest asks for one, budget \"heavy\" in plugin.inspect, installs only with it), grant?: [permission] (the permissions the user granted: a plug-in whose manifest lists permissions, needsApproval in plugin.inspect, installs only when grant holds every one of them; the grant is recorded with the module's hash and covers only that module, so a module with other bytes needs a new grant even when it lists the same permissions; installing a module that lists no permissions forgets the grant held for its id; with a plug-in store attached, only the grants kept there count), hash?: string (the module's SHA-256 as plugin.inspect gave it: a module with other bytes is refused)} validate and install a WebAssembly plug-in for this session → its plugin.list entry", always, install ), @@ -638,11 +722,28 @@ pub fn specs() -> Vec { "Inspect Plug-in", [], None, - "{path: a .wasm file (desktop) | dataBase64: the module, name?: the file name shown as its source (with dataBase64)} validate a WebAssembly plug-in without installing it → the plugin.list entry it would have, plus needsApproval: bool (true when it lists permissions the user hasn't granted this very module, its bytes included: plugin.install then needs grant)", + "{path: a .wasm file (desktop) | dataBase64: the module, name?: the file name shown as its source (with dataBase64)} validate a WebAssembly plug-in without installing it → the plugin.list entry it would have, plus needsApproval: bool (true when it lists permissions the user hasn't granted this very module, its bytes included: plugin.install then needs grant) and needsReplace: bool (true when it would take the place of another module with its id: plugin.install then needs replace: true)", always, inspect ), - cmd!(query "plugin.remove", "Remove Plug-in", [], None, "{id} uninstall a plug-in (its live effects then leave the geometry as it is) → {removed}", always, remove), + cmd!( + query "plugin.remove", + "Remove Plug-in", + [], + None, + "{id} uninstall a plug-in (its live effects then leave the geometry as it is) and delete the settings and cache it kept (when their folder can't be moved aside, the command fails and the plug-in stays installed); for an id that isn't installed, deletes the settings and cache the plug-in store still keeps for it → {removed}", + always, + remove + ), + cmd!( + query "plugin.clearCache", + "Clear Plug-in Cache", + [], + None, + "{id?} delete every entry in the cache of plug-in id (default: of every installed plug-in); its settings stay → {cleared: [id], bytesFreed}", + always, + clear_cache + ), cmd!( query "plugin.reload", "Reload Plug-ins", diff --git a/crates/engine/src/cmd/recovery.rs b/crates/engine/src/cmd/recovery.rs index 8b5731b33..a06597ac8 100644 --- a/crates/engine/src/cmd/recovery.rs +++ b/crates/engine/src/cmd/recovery.rs @@ -575,6 +575,10 @@ struct Meta { /// (see [`crate::DocState::import_losses`]): a restored copy still refuses to replace it. imported_from: Option, import_losses: Vec, + /// The file the links' relative paths name places from, for a copy without `path` (see + /// [`crate::DocState::links_from`]). + #[serde(skip_serializing_if = "Option::is_none")] + links_from: Option, /// The document's images by key, in entries of the area (none in copies written before /// images were kept apart: the document entry holds them). #[serde(skip_serializing_if = "BTreeMap::is_empty")] @@ -844,6 +848,7 @@ pub fn jobs(s: &mut Session, store: &Arc) -> Result<(Vec, file: &str) -> R if doc.title.is_empty() { doc.title = if meta.title.is_empty() { stem.to_string() } else { meta.title.clone() }; } - // Linked files are looked for from the document's own folder. - let links = super::links::resolve(&mut doc, meta.path.as_deref(), s.prefs.update_links == "automatically", s.prefs.low_res_proxy_eps); + // Linked files are looked for from the document's own folder, else from the file it was read from. + let links_from = meta.path.clone().or_else(|| meta.links_from.clone()); + let base = super::links::LinkBase::file(links_from.as_deref()); + let links = super::links::resolve(&mut doc, base, s.prefs.update_links == "automatically", s.prefs.low_res_proxy_eps); let index = s.add_document(doc, meta.path.clone()); let st = s.doc_mut()?; st.recovered = true; + st.links_from = links_from; st.imported_from = meta.imported_from.clone(); st.import_losses = meta.import_losses.clone(); st.format = SAVE_FORMATS.iter().copied().find(|f| *f == meta.format).unwrap_or("vectorcraft"); diff --git a/crates/engine/src/cmd/slices.rs b/crates/engine/src/cmd/slices.rs index f52c26a1c..5792c44fd 100644 --- a/crates/engine/src/cmd/slices.rs +++ b/crates/engine/src/cmd/slices.rs @@ -204,7 +204,7 @@ impl Session { /// The slices a command acts on: `slices` (ids that must all be slices), else the selected ones. fn slice_targets(s: &Session, p: &Value, cmd: &str) -> Result> { let st = s.doc()?; - match ids_param(p, "slices") { + match ids_param(p, "slices", cmd)? { Some(ids) => match ids.iter().find(|id| !st.doc.is_slice(**id)) { Some(id) => Err(bad(cmd, format!("{id} is not a slice"))), None => Ok(dedup(ids)), @@ -290,7 +290,7 @@ pub(crate) fn options_json(o: &SliceOptions) -> Value { // ---------- Object → Slice ---------- fn make(s: &mut Session, p: &Value) -> Result { - let ids = match ids_param(p, "ids") { + let ids = match ids_param(p, "ids", "object.slice.make")? { Some(ids) => super::edit::outermost(&s.doc()?.doc, ids), None => super::edit::selected_roots(s)?, }; @@ -734,7 +734,8 @@ fn delete(s: &mut Session, p: &Value) -> Result { } fn select(s: &mut Session, p: &Value) -> Result { - let ids = slice_targets(s, &json!({ "slices": p.get("slices").cloned().unwrap_or(json!([])) }), "object.slice.select")?; + let slices = p.get("slices").filter(|v| !v.is_null()).cloned().unwrap_or(json!([])); + let ids = slice_targets(s, &json!({ "slices": slices }), "object.slice.select")?; let toggle = bool_or(p, "toggle", false); s.select(|_, sel| { let mut now = if toggle { sel.slices.clone() } else { vec![] }; diff --git a/crates/engine/src/cmd/symboledit.rs b/crates/engine/src/cmd/symboledit.rs index de262cf2b..a3745975b 100644 --- a/crates/engine/src/cmd/symboledit.rs +++ b/crates/engine/src/cmd/symboledit.rs @@ -65,7 +65,8 @@ pub(crate) fn enter(s: &mut Session, p: &Value) -> Result { if st.doc.pattern_edit.is_some() { return Err(bad(C, "finish editing the pattern first")); } - let given = object_param(p, "id", C)?.map(|i| vec![i]).or_else(|| super::ids_param(p, "ids").filter(|v| !v.is_empty())); + let ids = super::ids_param(p, "ids", C)?; + let given = object_param(p, "id", C)?.map(|i| vec![i]).or_else(|| ids.filter(|v| !v.is_empty())); let (name, xf, instance) = match given { Some(ids) => { let symbols = ids.iter().map(|id| instance_of(&st.doc, *id)).collect::>>(); diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index d8514ef56..a88562b13 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -1068,7 +1068,7 @@ fn set_range_style(s: &mut Session, p: &Value) -> Result { if sets_font { // Empty text has one run, which the empty range styles. Characters that kept their font aren't the command's. let styled = if a == b { t.runs.clone() } else { unprotected_runs(&t.runs, a, b, &kept) }; - set.extend(styled.iter().map(|r| super::fonts::font_of(&r.style))); + set.extend(styled.iter().flat_map(super::fonts::run_fonts)); } refresh_bounds(t); n = if *f == id { t.runs.len() } else { n }; @@ -1140,7 +1140,12 @@ fn glyph_target(t: &TextObject, p: &Value, c: &str) -> Result<(char, std::ops::R return Err(bad(c, "an inline graphic has no glyphs")); } let db = vectorcraft_text::FontDb::global(); - let face = db.style_face(&run.style).filter(|_| db.has_family(&run.style.font_family)); + let face = match db.composite_resolve_at(&run.style.font_family, &run.text, start - at) { + // Type in a composite font: the character is drawn in the font of the part its + // grapheme cluster falls in. + Some((face, m)) => (m != vectorcraft_text::FontMatch::Missing).then_some(face), + None => db.style_face(&run.style).filter(|_| db.has_family(&run.style.font_family)), + }; let face = face.ok_or_else(|| bad(c, format!("the font {} is not available", run.style.font_family)))?; return Ok((ch, start..start + ch.len_utf8(), face)); } @@ -1602,6 +1607,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result { system_layout: false, fractional_widths: true, list_count: 0, + list_level_counts: Vec::new(), cached_bounds: None, cached_baselines: Vec::new(), }; @@ -1890,6 +1896,7 @@ fn headline_tracking(t: &TextObject, target: f64) -> Option { system_layout: t.system_layout, fractional_widths: t.fractional_widths, list_count: 0, + list_level_counts: Vec::new(), cached_bounds: None, cached_baselines: Vec::new(), }; diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index 17b2f0f0c..68394e7fd 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -327,7 +327,7 @@ fn font_change(before: &CharStyle, mut st: CharStyle, attrs: &Map /// Change text in layers, symbols, graph designs, pattern tiles, saved pattern tiles restored by /// Cancel, and brush art, including opacity masks in those trees. -fn each_text_mut(d: &mut Document, f: &mut impl FnMut(&mut vectorcraft_doc::TextObject) -> Result<()>) -> Result<()> { +pub(crate) fn each_text_mut(d: &mut Document, f: &mut impl FnMut(&mut vectorcraft_doc::TextObject) -> Result<()>) -> Result<()> { fn has_text(n: &Node) -> bool { matches!(n.kind, NodeKind::Text(_)) || n.extras().mask.as_ref().is_some_and(|mask| has_text(&mask.art)) diff --git a/crates/engine/src/cmd/threads.rs b/crates/engine/src/cmd/threads.rs index 61fc7f33a..ae15ab8b9 100644 --- a/crates/engine/src/cmd/threads.rs +++ b/crates/engine/src/cmd/threads.rs @@ -195,6 +195,7 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) { || optical != t.optical_margin || vertical != t.vertical || part.list_count != t.list_count + || part.list_level_counts != t.list_level_counts || continues != t.first_para_continues { t.runs = next.runs; @@ -203,6 +204,7 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) { t.optical_margin = optical; t.vertical = vertical; t.list_count = part.list_count; + t.list_level_counts = part.list_level_counts; t.first_para_continues = continues; refresh_bounds(t); } @@ -216,14 +218,15 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) { } /// Type that is not a frame of a thread is a story of its own: its numbered list paragraphs count -/// from 1. Each such type object with another [`TextObject::list_count`] (a copy of a frame, a -/// frame taken out of its thread, a frame converted to point type, which [`reflow`] takes out of -/// its thread) gets 0 and is laid out again. +/// from 1. Each such type object with another [`TextObject::list_count`] or +/// [`TextObject::list_level_counts`] (a copy of a frame, a frame taken out of its thread, a frame +/// converted to point type, which [`reflow`] takes out of its thread) gets 0 at every level and is +/// laid out again. pub(crate) fn restart_unthreaded_counts(doc: &mut std::sync::Arc) { let mut stale = vec![]; doc.walk(|n| { if let NodeKind::Text(t) = &n.kind - && t.list_count != 0 + && (t.list_count != 0 || !t.list_level_counts.is_empty()) { stale.push(n.id); } @@ -240,6 +243,7 @@ pub(crate) fn restart_unthreaded_counts(doc: &mut std::sync::Arc) { for id in stale { if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) { t.list_count = 0; + t.list_level_counts.clear(); refresh_bounds(t); } } @@ -444,13 +448,13 @@ fn cut_to_shown(n: &mut Node, ids: &[NodeId]) { { // Split as `take_overflow` splits it: the first part is what the frame shows. let from: &TextObject = t; - let parts = vectorcraft_text::thread::distribute_continuing( + let parts = vectorcraft_text::thread::distribute_continuing_levels( vectorcraft_text::FontDb::global(), &[from, from], &from.runs, &from.paragraph_styles(), from.first_para_continues, - from.list_count, + from.list_counts(), ); if let (Some(head), Some(_)) = (parts.first(), parts.get(1).filter(|p| p.runs.iter().any(|r| !r.text.is_empty()))) { t.runs = head.runs.clone(); @@ -485,13 +489,13 @@ fn take_overflow(d: &mut Document, to: NodeId, from: &TextObject) { // `from`'s text split as if it flowed on into a second frame: the second part is what `from` // has no room for. Its share is measured as it is laid out, continuing a paragraph or not and // with its numbered list paragraphs counted on from the frames before it. - let parts = vectorcraft_text::thread::distribute_continuing( + let parts = vectorcraft_text::thread::distribute_continuing_levels( db, &[from, from], &from.runs, &from.paragraph_styles(), from.first_para_continues, - from.list_count, + from.list_counts(), ); let Some(rest) = parts.get(1).filter(|p| p.runs.iter().any(|r| !r.text.is_empty())) else { return }; let mut tail = from.clone(); diff --git a/crates/engine/src/cmd/threads/tests.rs b/crates/engine/src/cmd/threads/tests.rs index 1ead2e823..a1358557c 100644 --- a/crates/engine/src/cmd/threads/tests.rs +++ b/crates/engine/src/cmd/threads/tests.rs @@ -981,6 +981,74 @@ fn outlining_a_numbered_last_frame_keeps_only_the_text_it_has_no_room_for() { assert_eq!(text(&s, a), format!("{}{hidden}", shown[0])); } +/// As above for a list at level 2: the last frame's numbered paragraphs are counted on from the +/// count the frames before it reached at that level. +#[test] +fn outlining_a_last_frame_numbered_at_a_deeper_level_keeps_only_the_text_it_has_no_room_for() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); + let items: String = (1..=7).map(|i| format!("Item {i}\n")).collect(); + let story = format!("{items}{}", TEXT.repeat(4)); + let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "area": {"width": 150, "height": 106}})).unwrap()); + let c = new_id(s.execute("text.create", &json!({"x": 200, "y": 10, "text": "", "area": {"width": 150, "height": 20}})).unwrap()); + s.execute("text.thread.add", &json!({"from": a, "to": c})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [a, c], "number": "upperRoman", "listLevel": 2, "leftIndent": 14, "firstLineIndent": -14})).unwrap(); + let shown = [a, c].map(|id| text(&s, id)); + assert!(shown[0].ends_with("Item 7\n") && shown[1].starts_with("The quick"), "{shown:?}"); + let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(c)).cloned() else { panic!("not area type") }; + assert_eq!((t.list_count, t.list_level_counts.as_slice()), (0, &[7][..])); + let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + assert_eq!(laid.markers.first().map(|m| m.text.as_str()), Some("VIII.")); + assert!(laid.overflow, "the last frame has more text than it shows"); + let end = laid.lines.last().map_or(0, |l| l.end); + let from_one = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &TextObject { list_level_counts: vec![], ..t }); + assert_ne!(from_one.lines.last().map_or(0, |l| l.end), end, "counted from 1, the frame would show other text"); + let hidden = shown[1][end..].trim_start_matches(' '); + s.execute("select.set", &json!({"ids": [c]})).unwrap(); + s.execute("type.createOutlines", &json!({})).unwrap(); + assert_eq!(text(&s, a), format!("{}{hidden}", shown[0])); +} + +/// New Symbol and Redefine Symbol on a thread's last frame numbered at level 2: the symbol's +/// art holds the text the frame shows, measured with the count the frames before it reached at +/// that level, and the frame before it takes the rest. +#[test] +fn a_symbol_of_a_last_frame_numbered_at_a_deeper_level_holds_the_text_it_shows() { + for command in ["symbol.new", "symbol.update"] { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); + let items: String = (1..=7).map(|i| format!("Item {i}\n")).collect(); + let story = format!("{items}{}", TEXT.repeat(4)); + let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "area": {"width": 150, "height": 106}})).unwrap()); + let c = new_id(s.execute("text.create", &json!({"x": 200, "y": 10, "text": "", "area": {"width": 150, "height": 20}})).unwrap()); + s.execute("text.thread.add", &json!({"from": a, "to": c})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [a, c], "number": "upperRoman", "listLevel": 2, "leftIndent": 14, "firstLineIndent": -14})) + .unwrap(); + let shown = [a, c].map(|id| text(&s, id)); + assert!(shown[0].ends_with("Item 7\n") && shown[1].starts_with("The quick"), "{command}: {shown:?}"); + let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(c)).cloned() else { panic!("not area type") }; + let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + assert!(laid.overflow, "{command}: the last frame has more text than it shows"); + let end = laid.lines.last().map_or(0, |l| l.end); + let from_one = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &TextObject { list_level_counts: vec![], ..t }); + assert_ne!(from_one.lines.last().map_or(0, |l| l.end), end, "{command}: counted from 1, the frame would show other text"); + let hidden = shown[1][end..].trim_start_matches(' '); + let name = if command == "symbol.update" { + let mark = new_id(s.execute("shape.rectangle", &json!({"x": 400, "y": 200, "width": 20, "height": 20})).unwrap()); + s.execute("select.set", &json!({"ids": [mark]})).unwrap(); + s.execute("symbol.new", &json!({"name": "Mark"})).unwrap(); + "Mark" + } else { + "Story" + }; + s.execute("select.set", &json!({"ids": [c]})).unwrap(); + s.execute(command, &json!({"name": name})).unwrap(); + let held = symbol_texts(&s, name); + assert_eq!(held.iter().map(|t| t.trim_end()).collect::>(), [shown[1][..end].trim_end()], "{command}: the symbol's art"); + assert_eq!(text(&s, a), format!("{}{hidden}", shown[0]), "{command}: the frame before it takes the rest"); + } +} + /// A story of one-line paragraphs that each start with a quotation mark, threaded from area /// type `a` into the rectangle `b`, which shows the paragraphs that `a` has no room for. fn quoted_thread() -> (Session, u64, u64) { @@ -1325,6 +1393,41 @@ fn numbered_list_paragraphs_count_on_through_a_thread() { assert_eq!(numbers(&s, b), all(1), "the frame left holds the story"); } +/// List levels in a thread: a deeper level's count runs on into the next frame, which saves +/// it (`listLevelCounts`), and a pasted copy of that frame counts every level from 1. +#[test] +fn list_levels_count_on_through_a_thread() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); + let items = (1..=10).map(|i| format!("Item {i}")).collect::>().join("\n"); + let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": items, "area": {"width": 150, "height": 60}})).unwrap()); + let b = new_id(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number", "listLevel": 2})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [a], "listLevel": 1, "start": 0, "end": 0})).unwrap(); + let frame = |s: &Session, id: u64| text_of(&s.doc().unwrap().doc, NodeId(id)).cloned().unwrap(); + let numbers = + |t: &TextObject| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).markers.iter().map(|m| m.text.clone()).collect::>(); + let first = numbers(&frame(&s, a)); + assert!((2..10).contains(&first.len()), "both frames hold items: {first:?}"); + let expected: Vec = std::iter::once("1.".to_string()).chain((1..=9).map(|n| format!("{n}."))).collect(); + assert_eq!([first.clone(), numbers(&frame(&s, b))].concat(), expected); + let b_counts = frame(&s, b).list_counts(); + assert_eq!(b_counts.parts(), (1, vec![first.len() as u32 - 1])); + let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let Some(NodeKind::Text(back)) = reopened.node(NodeId(b)).map(|n| &n.kind) else { panic!("text") }; + assert_eq!((back.list_counts(), numbers(back)), (b_counts, numbers(&frame(&s, b))), "reopened"); + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("edit.paste", &json!({})).unwrap(); + let copy = s.doc().unwrap().selection.objects.first().copied().unwrap(); + assert_ne!(copy, NodeId(b)); + let pasted = frame(&s, copy.0); + assert_eq!((pasted.list_count, pasted.list_level_counts.as_slice()), (0, &[][..])); + assert_eq!(numbers(&pasted).first().map(String::as_str), Some("1."), "a pasted copy counts from 1"); +} + /// New Symbol from a numbered thread keeps each frame's count in the symbol's art, so the art /// looks as it did; Break Link puts the frames in no thread, and each counts from 1. #[test] diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index b349ef894..c0e5b37fe 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -919,7 +919,7 @@ fn set_style(s: &mut Session, p: &Value) -> Result { super::textedit::unprotected_runs(&t.runs, a, b, &kept) } }; - set.extend(styled.iter().map(|r| super::fonts::font_of(&r.style))); + set.extend(styled.iter().flat_map(super::fonts::run_fonts)); } if let Some(j) = justify { let span = para_span(range, t); @@ -1279,7 +1279,7 @@ fn headline_fit(t: &TextObject, tracking: f64) -> Option<(f64, f64, usize)> { let cell = lay.frames.first()?; let line = lay.lines.first()?; let para = t.para_at(0); - let avail = cell.width() - 2.0 * t.area.inset - para.left_indent - para.right_indent; + let avail = cell.width() - 2.0 * t.area.inset - para.laid_out_left_indent() - para.right_indent; Some((line.x1 - line.x0, avail, line.end)) } diff --git a/crates/engine/src/cmd/typecmd/area_tests.rs b/crates/engine/src/cmd/typecmd/area_tests.rs index 4748dcc8d..0891bec5c 100644 --- a/crates/engine/src/cmd/typecmd/area_tests.rs +++ b/crates/engine/src/cmd/typecmd/area_tests.rs @@ -437,7 +437,7 @@ fn bulleted_lists_are_set_per_paragraph_saved_and_continued() { assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["list"], json!({"bullet": "square"})); // A saved list reads the fields it leaves out as their defaults and ignores those it doesn't know. let read = |v: serde_json::Value| serde_json::from_value::(json!({ "list": v })).unwrap().list; - assert_eq!((read(json!({})), read(json!({"bullet": "dash", "level": 2}))), (dot, Some(ParaList::bulleted(Bullet::Dash)))); + assert_eq!((read(json!({})), read(json!({"bullet": "dash", "restart": 2}))), (dot, Some(ParaList::bulleted(Bullet::Dash)))); let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); let Some(NodeKind::Text(back)) = reopened.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; assert_eq!((back.para_at(0).list, back.para_at(1).list, back.para_at(2).list), (square, dot, None)); @@ -500,7 +500,7 @@ fn numbered_lists_are_set_per_paragraph_saved_and_styled() { s.execute("text.setFormat", &json!({"bullet": "square", "start": 0, "end": 0})).unwrap(); assert_eq!(list(&s, 0), Some(ParaList::bulleted(Bullet::Square))); s.execute("text.setFormat", &json!({"number": "lowerLetters", "start": 0, "end": 0})).unwrap(); - let letters = Some(ParaList { bullet: Bullet::Square, number: Some(NumberStyle::LowerLetters) }); + let letters = Some(ParaList { bullet: Bullet::Square, number: Some(NumberStyle::LowerLetters), ..ParaList::default() }); assert_eq!(list(&s, 0), letters); assert_eq!(numbers(&s), ["a.", "2.", "3."]); assert_eq!((text(&s).para_at(0).left_indent, text(&s).para_at(0).first_line_indent), (18.0, -18.0)); @@ -536,6 +536,70 @@ fn numbered_lists_are_set_per_paragraph_saved_and_styled() { assert_eq!((list(&s, 0), list(&s, 1), list(&s, 2)), (None, None, None)); } +/// Bullets and Numbering… › Alignment: `text.setFormat {listAlign}` sets where the marker +/// stands in the list paragraphs it touches, in one undo step, and refuses unknown values; +/// a paragraph in no list gets no list from it. A list keeps its alignment when it changes +/// between bullets and numbers, and a call that puts a paragraph in a list gives it the +/// alignment too. The alignment is saved only when it is right, opens with the file, and +/// paragraph styles carry it both ways. +#[test] +fn list_markers_are_aligned_per_paragraph_saved_and_styled() { + use vectorcraft_doc::text::{Bullet, ListAlign, NumberStyle, ParaList}; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "One\nTwo\nThree", "area": {"width": 300, "height": 200}})).unwrap()["id"] + .as_u64() + .unwrap(); + let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("text"), + }; + let list = |s: &Session, i: usize| text(s).para_at(i).list; + let align = |s: &Session| (0..3).map(|i| list(s, i).map(|l| l.align)).collect::>(); + let (left, right) = (Some(ListAlign::Left), Some(ListAlign::Right)); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("text.setFormat", &json!({"list": "number", "start": 0, "end": 4})).unwrap(); + assert_eq!(align(&s), [left, left, None]); + let undo = s.doc().unwrap().history.undo.len(); + s.execute("text.setFormat", &json!({"listAlign": "right", "start": 0, "end": 0})).unwrap(); + assert_eq!(align(&s), [right, left, None]); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(align(&s), [left, left, None]); + s.execute("text.setFormat", &json!({"listAlign": "right"})).unwrap(); + assert_eq!(align(&s), [right, right, None], "the paragraph in no list stays out of one"); + for bad in [json!({"listAlign": "center"}), json!({"listAlign": "Right"}), json!({"listAlign": 1}), json!({"listAlign": true})] { + assert!(s.execute("text.setFormat", &bad).is_err(), "{bad}"); + } + assert_eq!(align(&s), [right, right, None], "an unknown value changes nothing"); + // Bullets and numbers keep it; a call that makes a list sets it there too. + s.execute("text.setFormat", &json!({"bullet": "square", "start": 0, "end": 0})).unwrap(); + assert_eq!(list(&s, 0), Some(ParaList::bulleted(Bullet::Square).aligned(ListAlign::Right))); + s.execute("text.setFormat", &json!({"list": "bullet", "listAlign": "right", "start": 8, "end": 8})).unwrap(); + assert_eq!(list(&s, 2), Some(ParaList::bulleted(Bullet::Dot).aligned(ListAlign::Right))); + s.execute("text.setFormat", &json!({"listAlign": "left", "start": 8, "end": 8})).unwrap(); + assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["list"], json!({"bullet": "square", "align": "right"})); + assert_eq!(serde_json::to_value(text(&s).para_at(2)).unwrap()["list"], json!({"bullet": "dot"})); + let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let Some(NodeKind::Text(back)) = reopened.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; + assert_eq!((back.para_at(0).list, back.para_at(1).list), (list(&s, 0), Some(ParaList::numbered(NumberStyle::Arabic).aligned(ListAlign::Right)))); + // A list turned off and on again starts aligned left. + s.execute("text.setFormat", &json!({"list": "none", "start": 4, "end": 4})).unwrap(); + s.execute("text.setFormat", &json!({"list": "number", "start": 4, "end": 4})).unwrap(); + assert_eq!(align(&s)[1], left); + // A style captured from a list aligned right gives other lists that alignment, and one + // captured from a list aligned left takes it back. + let other = s.execute("text.create", &json!({"x": 10, "y": 300, "text": "Other"})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.setFormat", &json!({"ids": [other], "list": "number"})).unwrap(); + s.execute("paraStyle.new", &json!({"name": "Aligned left", "id": other})).unwrap(); + s.execute("paraStyle.new", &json!({"name": "Aligned right", "id": id})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Aligned right", "id": other})).unwrap(); + let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(other)).map(|n| &n.kind) else { panic!("text") }; + assert_eq!(t.para_at(0).list.map(|l| l.align), right); + s.execute("paraStyle.apply", &json!({"name": "Aligned left", "id": id})).unwrap(); + assert_eq!(align(&s), [left, left, left]); +} + /// The text object `id`. fn text_at(s: &Session, id: u64) -> vectorcraft_doc::text::TextObject { match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { @@ -724,6 +788,56 @@ fn point_and_vertical_type_convert(s: &mut Session) { assert_eq!(s.doc().unwrap().history.undo.len(), steps); } +/// Convert to Text of a list whose markers are aligned right writes the same characters as for +/// a list aligned left, the marker and then a tab, and gives the paragraphs the same tab stops: +/// the text after the tab stays where it was, and the marker moves to the first line's start, +/// where a list aligned left draws it. Numbers narrower than the hanging indent (their tab went +/// to the left indent) and wider (their tab went to the next default stop), and bullets. +#[test] +fn convert_to_text_of_markers_aligned_right_keeps_the_text_in_place() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let text_glyphs = |s: &Session, id: u64| { + let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + let milli = |v: f64| (v * 1000.0).round() as i64; + lay.glyphs + .iter() + .filter(|g| g.len > 0 && !g.outline.elements().is_empty()) + .map(|g| (g.gid, milli(g.origin.x), milli(g.origin.y))) + .collect::>() + }; + let plain = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.plain_text(), + _ => panic!("text"), + }; + let items = (1..=10).map(|i| format!("Item {i}")).collect::>().join("\n"); + for (list, converted) in [ + ("number", (1..=10).map(|i| format!("{i}.\tItem {i}")).collect::>().join("\n")), + ("bullet", (1..=10).map(|i| format!("\u{2022}\tItem {i}")).collect::>().join("\n")), + ] { + let make = |s: &mut Session, align: &str| { + let id = s.execute("text.create", &json!({"x": 300, "y": 10, "size": 20, "text": items, "area": {"width": 300, "height": 600}})).unwrap() + ["id"] + .as_u64() + .unwrap(); + s.execute("text.setFormat", &json!({"ids": [id], "list": list, "listAlign": align})).unwrap(); + id + }; + let (left, right) = (make(&mut s, "left"), make(&mut s, "right")); + let drawn_left = drawn_glyphs(&s, left); + let text_before = text_glyphs(&s, right); + assert_eq!(text_before, text_glyphs(&s, left), "{list}: the text starts at the same place with either alignment"); + assert_ne!(drawn_glyphs(&s, right), drawn_left, "{list}: the markers aligned right are drawn elsewhere"); + s.execute("text.convertListToText", &json!({"ids": [left, right]})).unwrap(); + assert_eq!((plain(&s, left), plain(&s, right)), (converted.clone(), converted), "{list}: the marker, then a tab"); + let after = text_glyphs(&s, right); + assert!(text_before.iter().all(|g| after.contains(g)), "{list}: the text stays where it was"); + assert_eq!(drawn_glyphs(&s, right), drawn_left, "{list}: the markers start where a list aligned left draws them"); + assert_eq!(drawn_glyphs(&s, left), drawn_left, "{list}: aligned left, every glyph stays"); + } +} + /// A right-to-left list paragraph converted to text starts with the number, its period and a /// tab, which are read first: the line draws its glyphs in the order it drew them with the /// marker, the number at the right with its period at its left. Letters and roman numerals @@ -907,6 +1021,130 @@ fn convert_to_text_moves_the_type_tools_selection() { assert_eq!(s.active().unwrap().selection.objects, vec![NodeId(a)], "the first frame is selected"); } +/// Convert to Text of list paragraphs at deeper levels writes each level's own numbers, those +/// a thread's earlier frames counted at each level included, and every glyph is drawn where it +/// was: each converted paragraph's left indent takes its level's indent, with the plain left +/// stop there. +#[test] +fn convert_to_text_keeps_deeper_list_levels_in_place() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let text = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("text"), + }; + let numbers = |s: &Session, id: u64| { + vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(s, id)).markers.iter().map(|m| m.text.clone()).collect::>() + }; + let text_in = "One\nTwo\nThree\nFour\nFive"; + let id = s.execute("text.create", &json!({"x": 10, "y": 10, "size": 20, "text": text_in, "area": {"width": 400, "height": 400}})).unwrap()["id"] + .as_u64() + .unwrap(); + s.execute("text.setFormat", &json!({"ids": [id], "list": "number"})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [id], "listLevel": 2, "start": 4, "end": 9})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [id], "listLevel": 3, "start": 14, "end": 14})).unwrap(); + let before = drawn_glyphs(&s, id); + s.execute("text.convertListToText", &json!({"ids": [id]})).unwrap(); + assert_eq!(drawn_glyphs(&s, id), before, "every glyph is drawn where it was"); + let t = text(&s, id); + assert_eq!(t.plain_text(), "1.\tOne\n1.\tTwo\n2.\tThree\n1.\tFour\n2.\tFive"); + let paras = (0..5) + .map(|k| { + let p = t.para_at(k); + (p.list.is_none(), p.left_indent, p.tabs.iter().map(|tab| tab.position).collect::>()) + }) + .collect::>(); + assert_eq!(paras, [18.0, 36.0, 36.0, 54.0, 18.0].map(|x| (true, x, vec![x])), "out of the list, at the level's indent"); + // A thread whose second frame goes on with the numbers of level 2. + let items = std::iter::once("Head".to_string()).chain((1..=9).map(|i| format!("Item {i}"))).collect::>().join("\n"); + let a = + s.execute("text.create", &json!({"x": 10, "y": 500, "text": items, "area": {"width": 150, "height": 60}})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 500, "width": 150, "height": 200})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number", "listLevel": 2})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [a], "listLevel": 1, "start": 0, "end": 0})).unwrap(); + let first = numbers(&s, a).len(); + assert!((3..10).contains(&first) && text(&s, a).plain_text().ends_with('\n'), "both frames hold items: {first}"); + assert_eq!(numbers(&s, b).first(), Some(&format!("{first}.")), "the second frame counts level 2 on"); + let before = (drawn_glyphs(&s, a), drawn_glyphs(&s, b)); + s.execute("text.convertListToText", &json!({"ids": [a, b]})).unwrap(); + assert_eq!((drawn_glyphs(&s, a), drawn_glyphs(&s, b)), before, "both frames"); + let story = std::iter::once("1.\tHead".to_string()).chain((1..=9).map(|i| format!("{i}.\tItem {i}"))).collect::>().join("\n"); + assert_eq!(text(&s, a).plain_text() + &text(&s, b).plain_text(), story); +} + +/// List levels: `text.setFormat {listLevel}` sets the level of the list paragraphs it touches +/// as one undo step, from 1 to 9, and refuses other values; bullets and numbers keep it, a +/// list turned off and on starts at level 1 again, each level counts its own numbers, and the +/// level is saved past level 1, reopened and carried by paragraph styles. +#[test] +fn list_levels_are_set_per_paragraph_saved_and_styled() { + use vectorcraft_doc::text::{Bullet, NumberStyle, ParaList}; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = + s.execute("text.create", &json!({"x": 10, "y": 10, "text": "One\nTwo\nThree\nFour", "area": {"width": 300, "height": 200}})).unwrap()["id"] + .as_u64() + .unwrap(); + let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("text"), + }; + let levels = |s: &Session| (0..4).map(|i| text(s).para_at(i).list.map(|l| l.level)).collect::>(); + let numbers = |s: &Session| { + vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(s)).markers.iter().map(|m| m.text.clone()).collect::>() + }; + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("text.setFormat", &json!({"list": "number", "start": 0, "end": 9})).unwrap(); + assert_eq!(levels(&s), [Some(1), Some(1), Some(1), None]); + let undo = s.doc().unwrap().history.undo.len(); + s.execute("text.setFormat", &json!({"listLevel": 2, "start": 4, "end": 9})).unwrap(); + assert_eq!(levels(&s), [Some(1), Some(2), Some(2), None]); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + assert_eq!(numbers(&s), ["1.", "1.", "2."]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(levels(&s), [Some(1), Some(1), Some(1), None]); + s.execute("text.setFormat", &json!({"listLevel": 2.0, "start": 4, "end": 4})).unwrap(); + assert_eq!(levels(&s)[1], Some(2)); + s.execute("text.setFormat", &json!({"listLevel": 9})).unwrap(); + assert_eq!(levels(&s), [Some(9), Some(9), Some(9), None], "the paragraph in no list stays out of one"); + let bad = [json!({"listLevel": 0}), json!({"listLevel": 10}), json!({"listLevel": 1.5}), json!({"listLevel": "2"}), json!({"listLevel": true})]; + for p in bad { + assert!(s.execute("text.setFormat", &p).is_err(), "{p}"); + } + let err = s.execute("text.setFormat", &json!({"listLevel": 10})).unwrap_err().to_string(); + assert!(err.contains("`listLevel` must be a whole number between 1 and 9 (got 10)"), "{err}"); + assert_eq!(levels(&s)[0], Some(9), "an unknown value changes nothing"); + // A deeper paragraph counts from 1, and one back at a shallower level goes on. + s.execute("text.setFormat", &json!({"listLevel": 1, "start": 0, "end": 0})).unwrap(); + s.execute("text.setFormat", &json!({"listLevel": 3, "start": 4, "end": 4})).unwrap(); + s.execute("text.setFormat", &json!({"listLevel": 1, "start": 8, "end": 8})).unwrap(); + assert_eq!(numbers(&s), ["1.", "1.", "2."]); + // Bullets and numbers keep the level; a call that makes a list sets it there too. + s.execute("text.setFormat", &json!({"bullet": "square", "start": 4, "end": 4})).unwrap(); + assert_eq!(text(&s).para_at(1).list, Some(ParaList::bulleted(Bullet::Square).at_level(3))); + s.execute("text.setFormat", &json!({"list": "bullet", "listLevel": 4, "start": 14, "end": 14})).unwrap(); + assert_eq!(text(&s).para_at(3).list, Some(ParaList::bulleted(Bullet::Dot).at_level(4))); + assert_eq!(serde_json::to_value(text(&s).para_at(1)).unwrap()["list"], json!({"bullet": "square", "level": 3})); + assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["list"], json!({"bullet": "dot", "number": "arabic"})); + let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let Some(NodeKind::Text(back)) = reopened.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; + assert_eq!((0..4).map(|i| back.para_at(i).list).collect::>(), (0..4).map(|i| text(&s).para_at(i).list).collect::>()); + // A list turned off and on again starts at level 1. + s.execute("text.setFormat", &json!({"list": "none", "start": 4, "end": 4})).unwrap(); + s.execute("text.setFormat", &json!({"list": "number", "start": 4, "end": 4})).unwrap(); + assert_eq!(text(&s).para_at(1).list, Some(ParaList::numbered(NumberStyle::Arabic))); + // A style captured from a paragraph at level 4 sets that level, and one captured at level 1 + // takes it back. + s.execute("paraStyle.new", &json!({"name": "Level 4", "id": id, "start": 14, "end": 14})).unwrap(); + s.execute("paraStyle.new", &json!({"name": "Level 1", "id": id, "start": 0, "end": 0})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Level 4", "id": id, "start": 0, "end": 0})).unwrap(); + assert_eq!(levels(&s)[0], Some(4)); + s.execute("paraStyle.apply", &json!({"name": "Level 1", "id": id})).unwrap(); + assert_eq!(levels(&s), [Some(1); 4]); +} + /// Type › Create Outlines makes a path of a list item's bullet as well as of its characters. #[test] fn create_outlines_makes_a_path_of_the_bullet() { @@ -1627,9 +1865,10 @@ fn variable_font_settings_are_a_character_attribute_saved_only_when_set() { s.execute("text.setStyle", &json!({"style": "Bold"})).unwrap(); assert!(runs(&s).iter().all(|r| r.style.font_axes.is_empty())); s.execute("text.setFormat", &json!({"fontAxes": {"wdth": 75}})).unwrap(); - s.execute("text.replaceFont", &json!({"from": {"family": FAMILY}, "to": {"family": "Inter"}})).unwrap(); + s.execute("text.replaceFont", &json!({"from": {"family": FAMILY}, "to": {"family": "Source Sans 3"}})).unwrap(); assert!(runs(&s).iter().all(|r| r.style.font_axes.is_empty())); - // A static font has no axes to set. + // A static font has no axes to set (Source Sans 3, not Inter: an installed Inter + // Variable would shadow the bundled static and give it axes). s.execute("text.setFormat", &json!({"fontAxes": {"wght": 650}})).unwrap(); assert_eq!(saved(&s), None); // A missing font keeps them as given, for when it is installed. @@ -1651,7 +1890,7 @@ fn variable_font_settings_are_a_character_attribute_saved_only_when_set() { {"tag": "wdth", "name": "Width", "min": 75.0, "default": 100.0, "max": 100.0, "value": 100.0, "hidden": false}, ]) ); - assert_eq!(s.execute("text.fontList", &json!({"family": "Inter"})).unwrap()["axes"], json!([])); + assert_eq!(s.execute("text.fontList", &json!({"family": "Source Sans 3"})).unwrap()["axes"], json!([])); } #[test] diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index 83dfd925d..0b0ea1042 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -56,7 +56,7 @@ pub fn specs() -> Vec { "Type on a Path Options…", ["Type", "Type on a Path"], None, - "{ids?|id?, start?: 0..1, end?: 0..1|null, flip?: bool, effect?: rainbow|skew|3dRibbon|stairStep|gravity, alignToPath?: ascender|descender|center|baseline, spacing?: pt} set the selected type on a path's options: start and end place its start and end brackets as fractions of the path's length (end null: the end of the path, or once round a closed path), and flip then turns the type to the other side of its path (the path runs the other way, the brackets swap ends); none given: query → the first object's {start, end, flip: false, effect, alignToPath, spacing}", + "{ids?|id?, start?: 0..1, end?: 0..1|null, flip?: bool, effect?: rainbow|skew|3dRibbon|stairStep|gravity, alignToPath?: ascender|descender|center|baseline, spacing?: pt} set the selected type on a path's options: start and end place its start and end brackets as fractions of the path's length (end null: the end of the path, or once round a closed path), and flip then turns the type to the other side of its path (the path runs the other way, the brackets swap ends); alignToPath runs each character's em box top (ascender), font bounding box bottom (descender), half the height of its H's top above the baseline (center) or baseline along the path, by the character's own font, size and vertical scale (vertical type: half an em either way, or none for center); none given: query → the first object's {start, end, flip: false, effect, alignToPath, spacing}", has_selection, path_options ), @@ -373,8 +373,10 @@ fn to_point(s: &mut Session, p: &Value) -> Result { } // The point origin sits where the alignment anchors the first line. let p0 = t.para_at(0); - let (x0, x1) = - lay.lines.first().map_or((fb.x0, fb.x1), |l| (l.avail.0 - p0.left_indent - p0.first_line_indent, l.avail.1 + p0.right_indent)); + let (x0, x1) = lay + .lines + .first() + .map_or((fb.x0, fb.x1), |l| (l.avail.0 - p0.laid_out_left_indent() - p0.first_line_indent, l.avail.1 + p0.right_indent)); let ox = match p0.justify { Justify::Center | Justify::JustifyCenter => (x0 + x1) * 0.5, Justify::Right | Justify::JustifyRight => x1, @@ -611,6 +613,9 @@ pub(crate) fn alt_code_char(code: u64) -> Option { fn unicode_char(u: &str) -> Option { let u = u.trim(); let hex = u.strip_prefix("U+").or_else(|| u.strip_prefix("u+")).or_else(|| u.strip_prefix("0x")).unwrap_or(u); + if hex.is_empty() || !hex.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } char::from_u32(u32::from_str_radix(hex, 16).ok()?).filter(|c| !c.is_control() || *c == '\t') } @@ -843,3 +848,18 @@ fn paste_plain(s: &mut Session, p: &Value) -> Result { } r } + +#[cfg(test)] +mod tests { + use super::unicode_char; + + #[test] + fn unicode_char_rejects_sign_in_hex_digits() { + assert_eq!(unicode_char("+B0"), None); + assert_eq!(unicode_char("U++B0"), None); + assert_eq!(unicode_char("U+"), None); + assert_eq!(unicode_char("00B0"), Some('°')); + assert_eq!(unicode_char("U+00B0"), Some('°')); + assert_eq!(unicode_char("0x00B0"), Some('°')); + } +} diff --git a/crates/engine/src/command_sets.rs b/crates/engine/src/command_sets.rs index 2224ddccc..9fcb7cd4c 100644 --- a/crates/engine/src/command_sets.rs +++ b/crates/engine/src/command_sets.rs @@ -121,13 +121,14 @@ pub(crate) const SELECTS_GIVEN_IDS: &[&str] = &[ /// The non-empty `ids` given to a command whose params description names `ids` as a key (the /// key callers outside the app may pass it, [`cmd::params::takes_key`]), or its `id` as `ids` of /// one when no `ids` are given and the description takes `id` so -/// ([`cmd::params::id_stands_for_ids`]), as [`cmd::targets`] reads them. +/// ([`cmd::params::id_stands_for_ids`]), as [`cmd::targets`] reads them. Entries of `ids` that are +/// not ids are left out of this list; `Session::execute` refuses them before it reads it. pub(crate) fn given_ids(description: &'static str, params: &Value) -> Option> { if !cmd::params::takes_key(description, "ids") { return None; } match params.get("ids").filter(|v| !v.is_null()) { - Some(_) => cmd::ids_param(params, "ids"), + Some(v) => v.as_array().map(|a| a.iter().filter_map(Value::as_u64).map(NodeId).collect()), None => cmd::id_param(params, "id").filter(|_| cmd::params::id_stands_for_ids(description)).map(|id| vec![id]), } .filter(|ids| !ids.is_empty()) diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs index 0baab9654..e70bf5c2d 100644 --- a/crates/engine/src/doc_state.rs +++ b/crates/engine/src/doc_state.rs @@ -95,6 +95,11 @@ pub struct DocState { /// so an export or save to it asks first ([`cmd::fileio::check_not_lossy_overwrite`]). Not saved. pub imported_from: Option, pub import_losses: Vec, + /// The file its links' stored relative paths name places from: the file the document was read + /// from, also when that file is not its own (such as a `.ai` file another app wrote). A save + /// writes the saved file's relative paths and leaves the open document's links as they are. + /// `None`: the document's own file. Not saved ([`crate::cmd::links::LinkBase`]). + pub links_from: Option, /// Transform Again after a perspective move or scale (Perspective Selection tool): the /// `perspective.transform` params it repeats. `None` once an ordinary transform follows. pub last_perspective: Option, @@ -296,6 +301,7 @@ impl DocState { layers_open, imported_from: None, import_losses: vec![], + links_from: None, last_perspective: None, opened_by: file_access::current(), empty_artboards: Default::default(), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 79aa06891..27972ef20 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -62,6 +62,14 @@ pub struct Session { /// While Cmd lends `tool` (a selection tool) for a drag: the tool it was lent to, which comes /// back as it was at the release ([`Session::pointer`]). pub(crate) lender: Option>, + /// The selection of the active document (its uid) as Cmd lent `tool`: what the lent tool's + /// gesture found, so that one that leaves it as it was picks nothing. + pub(crate) selection_at_lend: Option<(u64, vectorcraft_doc::Selection)>, + /// The selection a lent tool's gesture changed and left as it gave `lender` back, in the + /// document (its uid) it was made in: while it is still that document's selection, the anchors + /// in it are ones picked, not the Pen's last anchor, and Copy and Cut take them with the Pen or + /// Curvature tool back too (#6123). + pub(crate) lent_selection: Option<(u64, vectorcraft_doc::Selection)>, /// The selection tool chosen last (Selection, Direct Selection or Group Selection): the one Cmd /// lends the other tools. None until one is chosen. pub(crate) last_selection_tool: Option<&'static str>, @@ -164,6 +172,9 @@ pub struct Session { /// has inherited the selection's appearance: the documents ([`DocState::uid`]) whose newest /// history step takes the colors the batch finally left. pub(crate) batch_paint_docs: Vec, + /// The composite fonts (lowercase names) that the open documents defined when the font + /// database was last brought in step with them ([`cmd::compositefonts::sync`]). + pub(crate) composite_names: Vec, /// While a command that changes nothing in the document runs (a query, or one of /// [`command_sets::JOURNALED_READS`]): it reads the instance of an object selected in a symbol /// instance's art. @@ -249,6 +260,9 @@ pub struct Session { /// The Global Edit Options Start Global Edit matches with (`select.globalEditOptions`); not /// saved. pub global_edit_options: cmd::globaledit::Options, + /// Set while [`Session::tool_typed_text`] passes keyboard input to the active tool + /// ([`vectorcraft_tools::ToolContext::typed_from_keyboard`]). + keyboard_text: bool, } impl Default for Session { diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index d9ef5740b..b0619c992 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -42,6 +42,8 @@ impl Session { journal: Journal::default(), tool: vectorcraft_tools::create("selection"), lender: None, + selection_at_lend: None, + lent_selection: None, last_selection_tool: None, last_view: ViewInfo::default(), preview_memo: Default::default(), @@ -81,6 +83,7 @@ impl Session { batch_stash: None, batch_steps: None, batch_paint_docs: Vec::new(), + composite_names: Vec::new(), reading: false, place_hand_off: None, queue_hand_off: None, @@ -110,6 +113,7 @@ impl Session { link_stamps: Default::default(), link_scan: None, global_edit_options: Default::default(), + keyboard_text: false, } } @@ -173,10 +177,16 @@ impl Session { pub fn set_active(&mut self, index: usize) -> bool { if index < self.docs.len() { - if self.active != Some(index) { + let switched = self.active != Some(index); + if switched { self.reset_tool_for_doc_switch(); } self.active = Some(index); + // Type is drawn in the composite fonts of the document now in front, and measured again + // when they differ from those of the document before. + if switched && cmd::compositefonts::sync(self) { + self.refresh_document_text_bounds(index); + } true } else { false @@ -193,7 +203,7 @@ impl Session { format!("Untitled-{}", self.untitled_counter) } - /// Text layout bounds are a cache (not saved): compute them for a document just read, or + /// Text layout bounds are a cache (not saved): compute them after its fonts are registered, or /// selection boxes and hit testing would use the rough estimate until each text is edited, and /// what is fitted to type's bounds (an envelope warping it) would come out differently. That /// holds for the type in symbol definitions, pattern tiles, graph designs and opacity masks too, @@ -208,6 +218,14 @@ impl Session { } } + /// [`Session::refresh_text_bounds`] for open document `index`, as a change that is not an + /// edit: a saved document stays saved, and its undo history keeps its documents as they are. + fn refresh_document_text_bounds(&mut self, index: usize) { + if let Some(st) = self.docs.get_mut(index) { + st.change_unsaved(|doc| Self::refresh_text_bounds(Arc::make_mut(doc))); + } + } + /// Add a document and make it active. Between `edit.undoGroup.begin` and `edit.undoGroup.end`, /// an undo group with the begun group's name opens in it: here outside a command, and after the /// command that adds it otherwise ([`Session::execute`]). @@ -215,7 +233,7 @@ impl Session { // Every edit is checked against the document before it, so the first one is within the cap // (importers and the native reader have seen to theirs already, with a warning). doc.limit_depth(); - Self::refresh_text_bounds(&mut doc); + cmd::compositefonts::filter_loaded(&mut doc); // Guides from files saved before guides had layers go on a layer. doc.adopt_guides(); cmd::dimension::conform(&mut doc); @@ -226,6 +244,8 @@ impl Session { self.docs.push(st); let i = self.docs.len() - 1; self.active = Some(i); + cmd::compositefonts::sync(self); + self.refresh_document_text_bounds(i); if self.depth == 0 && let Some(label) = self.explicit_undo_group.clone() { @@ -241,7 +261,7 @@ impl Session { return false; } doc.limit_depth(); - Self::refresh_text_bounds(&mut doc); + cmd::compositefonts::filter_loaded(&mut doc); doc.adopt_guides(); cmd::dimension::conform(&mut doc); if self.active == Some(index) { @@ -277,6 +297,12 @@ impl Session { if let Some(stash) = &mut self.batch_stash { stash.push(old); } + // Type in the composite fonts of a document in the back is measured again when it comes to + // the front. + if self.active == Some(index) { + cmd::compositefonts::sync(self); + } + self.refresh_document_text_bounds(index); true } @@ -293,6 +319,7 @@ impl Session { stash.push(old); } self.active = if self.docs.is_empty() { None } else { Some(index.min(self.docs.len() - 1)) }; + cmd::compositefonts::sync(self); true } diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs index 71f90e7e4..5ad84885f 100644 --- a/crates/engine/src/session/execute.rs +++ b/crates/engine/src/session/execute.rs @@ -15,11 +15,14 @@ impl Session { r } - /// Error, under the command's own id, at the first of `ids` that is malformed, names no node, or - /// names a layer or sublayer: a command in [`SELECTS_GIVEN_IDS`] acts on objects. + /// Error, under the command's own id, at the first of `ids` that is malformed, else at the first + /// that names no node or names a layer or sublayer: a command in [`SELECTS_GIVEN_IDS`] acts on + /// objects. fn check_given_ids(&self, id: &str, params: &Value) -> Result<()> { let (Some(st), Some(ids)) = (self.active(), params.get("ids")) else { return Ok(()) }; let Some(given) = ids.as_array() else { return cmd::checked_ids_param(self, params, "ids", id).map(drop) }; + // A malformed entry anywhere in the list is refused first, under the key and the entry. + cmd::ids_param(params, "ids", id)?; for value in given { let node = cmd::checked_id(self, value, id)?; if st.doc.node(node).is_some_and(|n| n.is_layer()) { @@ -65,6 +68,13 @@ impl Session { if SELECTS_GIVEN_IDS.contains(&id) { self.check_given_ids(id, params)?; } + // An `ids` list with an entry that is not an object id, or an `ids` that is not a list, is + // refused under the command's id before the menu check, which would otherwise run on the + // other entries or on the selection. This holds with no document open too, where commands + // such as `effect.list` still run. + if cmd::params::takes_key(spec.params, "ids") { + cmd::ids_param(params, "ids", id)?; + } // Clear's menu needs a selection; an agent supplying explicit targets only needs a // document. The command validates those targets before making any edit. let given = given_ids(spec.params, params); @@ -147,6 +157,9 @@ impl Session { } if let Some(open) = open { self.open_undo_groups_in_new_documents(&open); + // Type is drawn in the composite fonts of the documents open now, an undone or opened + // document's included. + cmd::compositefonts::sync(self); } let journaled = spec.journal || (self.depth == 0 && std::mem::take(&mut self.journal_query)); if journaled && self.depth == 0 && self.active().is_none_or(|d| d.interaction.is_none()) { diff --git a/crates/engine/src/store.rs b/crates/engine/src/store.rs index 93b0839ba..824479d73 100644 --- a/crates/engine/src/store.rs +++ b/crates/engine/src/store.rs @@ -19,6 +19,11 @@ //! copied: it is loaded from where it is. `grants.json` holds what the user granted the plug-ins //! stored there that list permissions (id, module hash, permissions); a stored plug-in whose //! permissions it doesn't grant is skipped with a log record, and stays in the folder. +//! * **Plug-in settings and caches**: `storage/id-/` in the plug-in store, the id written as +//! [`case_safe`] writes it, holds what a plug-in +//! keeps with the `settings` and `cache` permissions ([`PluginStorage`]): `settings.json` and a +//! `cache` folder with one file per entry. The host names every file; a plug-in only names keys. +//! Replacing a plug-in keeps them, and `plugin.remove` deletes them. //! //! The files are untrusted input like any other: sizes and counts are capped, a file that does not //! parse is skipped with a log record, and every write is atomic. @@ -26,9 +31,12 @@ use std::collections::{BTreeMap, BTreeSet}; use std::io::Read; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; use vectorcraft_pdf::{PdfPreset, builtin_preset}; use vectorcraft_plugins::safe_mode; +use vectorcraft_plugins::storage::{self, Meter, Storage, StorageError}; use crate::Session; use crate::cmd::pdfcmds::{PRESET_FORMAT, PresetFile}; @@ -55,6 +63,25 @@ pub const DISABLED_LOCK: &str = "disabled.lock"; const GRANTS_FILE: &str = "grants.json"; /// The largest grants file read. const MAX_GRANTS_FILE: u64 = 1 << 20; +/// The folder in the plug-in store that holds each plug-in's settings and cache. +const PLUGIN_DATA_DIR: &str = "storage"; +/// A plug-in's settings file, in its folder. +const SETTINGS_FILE: &str = "settings.json"; +/// Where a settings file that doesn't read is moved, replacing the one moved there before. +const BAD_SETTINGS_FILE: &str = "settings.unreadable.json"; +/// The folder of a plug-in's cache entries, in its folder. +const CACHE_DIR: &str = "cache"; +/// The extension of a cache entry's file. +const CACHE_EXT: &str = "bin"; +/// Items in a cache folder a write looks at, at most: the entries, and temporary files a write left. +/// A write to a folder that holds more is refused as full. +pub(crate) const MAX_CACHE_SCAN: usize = storage::MAX_CACHE_ENTRIES * 2; +/// Held while a plug-in's settings or cache change, so two runs can't pass a cap together or lose +/// each other's settings. +static PLUGIN_DATA_LOCK: Mutex<()> = Mutex::new(()); +/// The closed flag of each plug-in folder's storage, shared by every storage made for the folder +/// until the plug-in is removed, which sets it and drops it. +static PLUGIN_DATA_OPEN: Mutex>> = Mutex::new(BTreeMap::new()); /// The file name (without extension) a stored item called `name` gets: its name made safe for any /// file system, then a hash of the name, so that names that differ only in characters a file name @@ -262,8 +289,9 @@ impl Session { /// Install the plug-ins kept in the plug-in folder: none in Safe Mode, and none the user /// disabled (matched by file name, which is made from the id). The launch marker names each /// file while it loads. A module that doesn't load, and one that lists permissions the grants - /// there don't grant that very module, is skipped with a log record, and stays in the folder - /// with its grant. A grant for an id whose stored module lists no permissions is forgotten. + /// there don't grant that very module ([`grant_covers`]), is skipped with a log record, and + /// stays in the folder with its grant. A grant for an id whose stored module lists no + /// permissions is forgotten. pub fn load_stored_plugins(&mut self) { let Some(dir) = self.plugin_dir.clone() else { return }; if safe_mode::active() { @@ -296,17 +324,21 @@ impl Session { // module stored here load. Ok(plugin) if plugin.manifest().asks_permissions() => { let m = plugin.manifest(); - match grants.get(&m.id).filter(|g| g.covers(&m.id, plugin.hash(), &m.permissions())) { + 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::set_storage(&m.id, plugin_storage(Some(&dir), &m.id)); vectorcraft_plugins::registry::install(plugin.with_source(path.display().to_string())); } - None => log::warn!("skipping the stored plug-in {}: the user hasn't approved this module's permissions", path.display()), + None => { + log::warn!("skipping the stored plug-in {}: the user hasn't granted this module the permissions it lists", path.display()) + } } } 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())); } Err(e) => log::warn!("skipping the stored plug-in {}: {e}", path.display()), @@ -357,7 +389,7 @@ impl Session { /// Whether `plugin` installs without asking the user: it lists no permissions, or this very /// module (its id and SHA-256) was granted every one it lists, in the grants of the attached - /// plug-in store when there is one, else in this process's + /// plug-in store when there is one ([`grant_covers`]), else in this process's /// ([`vectorcraft_plugins::registry::approved`]). pub(crate) fn plugin_approved(&self, plugin: &vectorcraft_plugins::Plugin) -> bool { self.plugin_approver()(plugin) @@ -370,7 +402,7 @@ impl Session { move |plugin| { let m = plugin.manifest(); match &grants { - Some(grants) if m.asks_permissions() => grants.get(&m.id).is_some_and(|g| g.covers(&m.id, plugin.hash(), &m.permissions())), + Some(grants) if m.asks_permissions() => grants.get(&m.id).is_some_and(|g| grant_covers(g, plugin)), _ => vectorcraft_plugins::registry::approved(plugin), } } @@ -382,6 +414,29 @@ impl Session { self.plugin_dir.as_deref().is_some_and(|dir| put(dir, &format!("{}.{PLUGIN_EXT}", file_stem(id)), bytes)) } + /// The module the store holds for the plug-in `id`: `None` without a folder or such a file, and + /// an error, with a log record, when the file can't be read or is larger than the module limit. + pub(crate) fn stored_plugin_bytes(&self, id: &str) -> Result>, String> { + let Some(dir) = self.plugin_dir.as_deref() else { return Ok(None) }; + let path = dir.join(format!("{}.{PLUGIN_EXT}", file_stem(id))); + if !path.exists() { + return Ok(None); + } + let max = vectorcraft_plugins::Limits::default().max_module_bytes as u64; + read_capped(&path, max).map(Some).inspect_err(|e| log::warn!("can't read the stored plug-in {}: {e}", path.display())) + } + + /// Return the store's module for the plug-in `id` to `previous`, the bytes it held before a + /// write ([`Session::stored_plugin_bytes`]): with `None`, the file is removed. + pub(crate) fn put_back_plugin(&self, id: &str, previous: Option<&[u8]>) { + match previous { + Some(bytes) => { + self.store_plugin(id, bytes); + } + None => self.unstore_plugin(id), + } + } + /// The file the plug-in `id` is stored in, spelled as [`Session::load_stored_plugins`] names it /// (`None` without a plug-in folder). pub(crate) fn stored_plugin_file(&self, id: &str) -> Option { @@ -392,14 +447,15 @@ impl Session { /// other grants as they are; with none left, remove the file. Entries this version can't use /// are written back as they were, except one for `id`. A grants file that is too large, can't /// be read or doesn't hold a list is left as it is, with a log record, and nothing is recorded; - /// so is one whose new content would be larger than `MAX_GRANTS_FILE`. - pub(crate) fn store_grant(&self, id: &str, grant: Option) { - let Some(dir) = &self.plugin_dir else { return }; + /// so is one whose new content would be larger than `MAX_GRANTS_FILE`. → whether the store now + /// holds what was asked (`false` without a folder). + pub(crate) fn store_grant(&self, id: &str, grant: Option) -> bool { + let Some(dir) = &self.plugin_dir else { return false }; let mut file = match read_grants(dir) { Ok(file) => file, Err(e) => { log::warn!("not updating the stored plug-in grants {}: {e}", dir.join(GRANTS_FILE).display()); - return; + return false; } }; let others = file.other.len(); @@ -409,22 +465,23 @@ impl Session { None => file.grants.remove(id).is_some(), } || file.other.len() != others; if !changed { - return; + return true; } let mut entries: Vec = file.grants.into_values().filter_map(|g| serde_json::to_value(g).ok()).collect(); entries.extend(file.other); if entries.is_empty() { take(dir, GRANTS_FILE); - return; + return true; } // The file is read back only up to its cap: written compactly when pretty-printed is larger, // and not at all when that is too. let fits = |bytes: &Vec| bytes.len() as u64 <= MAX_GRANTS_FILE; match serde_json::to_vec_pretty(&entries).ok().filter(fits).or_else(|| serde_json::to_vec(&entries).ok().filter(fits)) { - Some(bytes) => { - put(dir, GRANTS_FILE, &bytes); + Some(bytes) => put(dir, GRANTS_FILE, &bytes), + None => { + log::warn!("not updating the stored plug-in grants {}: {}", dir.join(GRANTS_FILE).display(), too_large(MAX_GRANTS_FILE)); + false } - None => log::warn!("not updating the stored plug-in grants {}: {}", dir.join(GRANTS_FILE).display(), too_large(MAX_GRANTS_FILE)), } } @@ -434,6 +491,380 @@ impl Session { take(dir, &format!("{}.{PLUGIN_EXT}", file_stem(id))); } } + + /// Keep the settings and cache of plug-in `id` in the attached plug-in store from now on; without + /// one, nowhere (its storage calls then fail as unavailable). + pub(crate) fn attach_plugin_storage(&self, id: &str) { + vectorcraft_plugins::registry::set_storage(id, plugin_storage(self.plugin_dir.as_deref(), id)); + } + + /// Does the attached plug-in store have a folder of settings and cache for plug-in `id`? A + /// folder behind a `storage` folder that is a link is not counted: the plug-in's storage calls + /// can't reach it and removing the plug-in leaves it. + pub(crate) fn has_plugin_storage(&self, id: &str) -> bool { + self.plugin_dir + .as_deref() + .is_some_and(|dir| !is_link(&dir.join(PLUGIN_DATA_DIR)) && std::fs::symlink_metadata(plugin_data_dir(dir, id)).is_ok()) + } + + /// Delete the settings and cache of plug-in `id` from the store, and stop keeping any for it. + /// The folder is first renamed out of the way (`.removed`), and that fails, leaving it + /// as it was, when the rename does; what the deletion then leaves stays under that name, with a + /// log record, and is deleted at the plug-in's next removal. + pub(crate) fn remove_plugin_storage(&self, id: &str) -> Result<(), StorageError> { + vectorcraft_plugins::registry::set_storage(id, None); + let Some(dir) = &self.plugin_dir else { return Ok(()) }; + let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let path = plugin_data_dir(dir, id); + // Every storage handed out for the folder, including those runs still hold, is closed once + // the folder is out of the way; until then they keep working. + let close = || { + if let Some(closed) = PLUGIN_DATA_OPEN.lock().unwrap_or_else(|e| e.into_inner()).remove(&path) { + closed.store(true, Ordering::Release); + } + }; + // A `storage` folder that is a link leads out of the store: nothing there is deleted. A link + // in place of the plug-in's own folder is removed itself (`remove_dir_all` doesn't follow it). + let data = dir.join(PLUGIN_DATA_DIR); + if is_link(&data) { + log::warn!("{} is a link: the plug-in settings and cache it leads to are left", data.display()); + close(); + return Ok(()); + } + let mut aside = path.clone().into_os_string(); + aside.push(".removed"); + let aside = PathBuf::from(aside); + let _ = std::fs::remove_dir_all(&aside); + match std::fs::rename(&path, &aside) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(StorageError::Io(format!("{}: {e}", path.display()))), + Ok(()) => {} + } + close(); + if std::fs::symlink_metadata(&aside).is_ok() + && let Err(e) = std::fs::remove_dir_all(&aside) + { + log::warn!("can't delete {}: {e}", aside.display()); + } + Ok(()) + } +} + +/// `name` (a plug-in id or a cache entry name, ASCII) written for a file name that no other name +/// gets on a file system that ignores case: `_` is written `__`, each capital letter `_` and the +/// letter in lower case, and `.` `_0`. +fn case_safe(name: &str) -> String { + let mut file = String::with_capacity(name.len() * 2); + for c in name.chars() { + match c { + '_' => file.push_str("__"), + '.' => file.push_str("_0"), + c if c.is_ascii_uppercase() => { + file.push('_'); + file.push(c.to_ascii_lowercase()); + } + c => file.push(c), + } + } + file +} + +/// The folder of plug-in `id`'s settings and cache in the plug-in store `dir`. The `id-` in front +/// keeps an id such as `con` from naming a device on Windows. +pub(crate) fn plugin_data_dir(dir: &Path, id: &str) -> PathBuf { + dir.join(PLUGIN_DATA_DIR).join(format!("id-{}", case_safe(id))) +} + +/// Where plug-in `id` keeps its settings and cache in the plug-in store `dir`. +fn plugin_storage(dir: Option<&Path>, id: &str) -> Option> { + let dir = dir?; + let data = plugin_data_dir(dir, id); + let closed = PLUGIN_DATA_OPEN.lock().unwrap_or_else(|e| e.into_inner()).entry(data.clone()).or_default().clone(); + Some(Arc::new(PluginStorage { root: dir.to_path_buf(), dir: data, closed })) +} + +/// The settings and cache of one plug-in, in its folder of the plug-in store (see the module docs). +/// Keys and entry names are [`storage::valid_name`]s; an entry's file name is its name as +/// [`case_safe`] writes it, so that names differing only in case get different files. A link +/// anywhere in the folder's path below the store is refused, and so is a cache file that is not a +/// plain file. +pub(crate) struct PluginStorage { + /// The plug-in store, and the plug-in's folder in it. + root: PathBuf, + dir: PathBuf, + /// Set, under [`PLUGIN_DATA_LOCK`], once the plug-in is removed; shared by every storage of the + /// folder ([`PLUGIN_DATA_OPEN`]). + closed: Arc, +} + +/// The sizes of `files` added up, at most `u64::MAX`: the sizes come from the file system. +fn sum_sizes(files: &[(PathBuf, u64)]) -> u64 { + files.iter().fold(0, |sum, (_, n)| sum.saturating_add(*n)) +} + +/// Is `name` the file name of a cache entry? Entry files end in `.bin` and never start with a `.` +/// ([`case_safe`] writes no `.`), while the temporary files of `write_atomic` do. +fn is_entry_file(name: &std::ffi::OsStr) -> bool { + name.to_str().is_some_and(|n| !n.starts_with('.') && n.ends_with(&format!(".{CACHE_EXT}"))) +} + +/// What a storage call is told when the file system fails. +fn io(e: &std::io::Error) -> StorageError { + StorageError::Io(e.to_string()) +} + +/// Does grant `g` cover `plugin`: was it given to this module (the same id and SHA-256) for every +/// permission the module lists? A module with other bytes under the id needs the user's approval +/// again, and so doesn't get the settings and cache kept for the id without it. A grant whose hash +/// is missing or not a valid SHA-256 covers no module. +fn grant_covers(g: &vectorcraft_plugins::permissions::Grant, plugin: &vectorcraft_plugins::Plugin) -> bool { + let m = plugin.manifest(); + g.covers(&m.id, plugin.hash(), &m.permissions()) +} + +/// Is `path` a link (a symbolic link or, on Windows, a junction)? +fn is_link(path: &Path) -> bool { + std::fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink() || reparse_point(&m)) +} + +#[cfg(windows)] +fn reparse_point(m: &std::fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; + m.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 +} + +#[cfg(not(windows))] +fn reparse_point(_: &std::fs::Metadata) -> bool { + false +} + +impl PluginStorage { + fn settings_path(&self) -> PathBuf { + self.dir.join(SETTINGS_FILE) + } + + fn cache_dir(&self) -> PathBuf { + self.dir.join(CACHE_DIR) + } + + /// The file of the cache entry `name`. + fn entry_path(&self, name: &str) -> PathBuf { + self.cache_dir().join(format!("{}.{CACHE_EXT}", case_safe(name))) + } + + /// Checks that no folder between the store and `path`, nor `path` itself, is a link. + fn no_links(&self, path: &Path) -> Result<(), StorageError> { + let mut at = path; + while at != self.root { + if is_link(at) { + return Err(StorageError::Unavailable); + } + match at.parent() { + Some(up) => at = up, + None => break, + } + } + Ok(()) + } + + /// The settings, by key. A file that is too large or doesn't parse is moved aside + /// ([`BAD_SETTINGS_FILE`]) with a log record, and the settings are empty. `meter` is charged the + /// file's size before it is read. + fn read_settings(&self, meter: &mut Meter) -> Result>, StorageError> { + let path = self.settings_path(); + self.no_links(&path)?; + let size = match std::fs::metadata(&path) { + Ok(m) => m.len(), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()), + Err(e) => return Err(io(&e)), + }; + let readable = size <= storage::MAX_SETTINGS_BYTES as u64; + if readable { + meter.take(size)?; + } + let parsed = readable + .then(|| read_capped(&path, storage::MAX_SETTINGS_BYTES as u64).ok()) + .flatten() + .and_then(|bytes| serde_json::from_slice::>(&bytes).ok()) + .and_then(|stored| { + let decode = + |(k, v): (String, String)| if storage::valid_name(&k) { vectorcraft_format::base64_decode(&v).map(|v| (k, v)) } else { None }; + let settings: Option>> = stored.into_iter().map(decode).collect(); + settings.filter(|s| s.len() <= storage::MAX_SETTINGS && s.values().all(|v| v.len() <= storage::MAX_SETTING_BYTES)) + }); + match parsed { + Some(settings) => Ok(settings), + None => { + let aside = self.dir.join(BAD_SETTINGS_FILE); + log::warn!("the plug-in settings {} don't read: moved to {} and started empty", path.display(), aside.display()); + std::fs::rename(&path, &aside).map_err(|e| io(&e))?; + Ok(BTreeMap::new()) + } + } + } + + /// Writes `settings` as the settings file, or removes it when there are none. `meter` is charged + /// the new file's size before it is written. + fn write_settings(&self, settings: &BTreeMap>, meter: &mut Meter) -> Result<(), StorageError> { + let path = self.settings_path(); + if settings.is_empty() { + return match std::fs::remove_file(&path) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(io(&e)), + _ => Ok(()), + }; + } + let stored: BTreeMap<&str, String> = settings.iter().map(|(k, v)| (k.as_str(), vectorcraft_format::base64_encode(v))).collect(); + let bytes = serde_json::to_vec(&stored).map_err(|e| StorageError::Io(e.to_string()))?; + if bytes.len() > storage::MAX_SETTINGS_BYTES { + return Err(StorageError::Full); + } + meter.take(bytes.len() as u64)?; + std::fs::create_dir_all(&self.dir).map_err(|e| io(&e))?; + self.no_links(&path)?; + write_atomic(&path, &bytes).map_err(|e| io(&e)) + } + + /// Fails with [`StorageError::Unavailable`] once the storage is closed. + fn open(&self) -> Result<(), StorageError> { + if self.closed.load(Ordering::Acquire) { Err(StorageError::Unavailable) } else { Ok(()) } + } + + /// The entries of the cache folder (plain files named as [`Self::entry_path`] names them, which + /// leaves out the temporary files of writes) and their sizes, and whether the folder holds more + /// than [`MAX_CACHE_SCAN`] items, in which case only the entries among the first that many are + /// listed. + fn cache_files(&self) -> Result<(Vec<(PathBuf, u64)>, bool), StorageError> { + let dir = self.cache_dir(); + self.no_links(&dir)?; + let entries = match std::fs::read_dir(&dir) { + Ok(entries) => entries, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok((vec![], false)), + Err(e) => return Err(io(&e)), + }; + let mut files = Vec::new(); + for (seen, e) in entries.enumerate() { + if seen == MAX_CACHE_SCAN { + return Ok((files, true)); + } + let e = e.map_err(|e| io(&e))?; + if is_entry_file(&e.file_name()) + && let Ok(m) = std::fs::symlink_metadata(e.path()) + && m.is_file() + && !reparse_point(&m) + { + files.push((e.path(), m.len())); + } + } + Ok((files, false)) + } +} + +impl Storage for PluginStorage { + fn setting(&self, key: &str, meter: &mut Meter) -> Result>, StorageError> { + let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + self.open()?; + Ok(self.read_settings(meter)?.remove(key)) + } + + fn set_setting(&self, key: &str, value: &[u8], meter: &mut Meter) -> Result<(), StorageError> { + let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + self.open()?; + let mut settings = self.read_settings(meter)?; + if value.len() > storage::MAX_SETTING_BYTES || (!settings.contains_key(key) && settings.len() >= storage::MAX_SETTINGS) { + return Err(StorageError::Full); + } + settings.insert(key.to_string(), value.to_vec()); + self.write_settings(&settings, meter) + } + + fn delete_setting(&self, key: &str, meter: &mut Meter) -> Result { + let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + self.open()?; + let mut settings = self.read_settings(meter)?; + if settings.remove(key).is_none() { + return Ok(false); + } + self.write_settings(&settings, meter).map(|()| true) + } + + fn cache_entry(&self, name: &str, meter: &mut Meter) -> Result>, StorageError> { + let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + self.open()?; + let path = self.entry_path(name); + self.no_links(&path)?; + match std::fs::symlink_metadata(&path) { + Ok(m) if m.is_file() && m.len() <= storage::MAX_CACHE_ENTRY as u64 => meter.take(m.len())?, + Ok(_) => return Err(StorageError::Unavailable), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(io(&e)), + } + read_capped(&path, storage::MAX_CACHE_ENTRY as u64).map(Some).map_err(StorageError::Io) + } + + fn set_cache_entry(&self, name: &str, value: &[u8], meter: &mut Meter) -> Result<(), StorageError> { + let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + self.open()?; + let path = self.entry_path(name); + let (files, overfull) = self.cache_files()?; + if overfull { + return Err(StorageError::Full); + } + let old = files.iter().find(|(p, _)| *p == path).map(|(_, n)| *n); + let total = sum_sizes(&files).saturating_sub(old.unwrap_or(0)).saturating_add(value.len() as u64); + let too_many = old.is_none() && files.len() >= storage::MAX_CACHE_ENTRIES; + if value.len() > storage::MAX_CACHE_ENTRY || total > storage::MAX_CACHE_BYTES || too_many { + return Err(StorageError::Full); + } + meter.take(value.len() as u64)?; + std::fs::create_dir_all(self.cache_dir()).map_err(|e| io(&e))?; + self.no_links(&path)?; + write_atomic(&path, value).map_err(|e| io(&e)) + } + + fn remove_cache_entry(&self, name: &str) -> Result { + let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + self.open()?; + let path = self.entry_path(name); + self.no_links(&path)?; + match std::fs::remove_file(&path) { + Ok(()) => Ok(true), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(e) => Err(io(&e)), + } + } + + fn clear_cache(&self) -> Result { + let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + self.open()?; + let dir = self.cache_dir(); + self.no_links(&dir)?; + let entries = match std::fs::read_dir(&dir) { + Ok(entries) => entries, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(0), + Err(e) => return Err(io(&e)), + }; + // Every plain file, one at a time as the folder is read, past the ones a write looks at too. + let mut freed = 0_u64; + for e in entries { + let path = e.map_err(|e| io(&e))?.path(); + if let Ok(m) = std::fs::symlink_metadata(&path) + && m.is_file() + && !reparse_point(&m) + { + std::fs::remove_file(&path).map_err(|e| io(&e))?; + freed = freed.saturating_add(m.len()); + } + } + Ok(freed) + } + + fn cache_bytes(&self) -> u64 { + if self.open().is_err() { + return 0; + } + self.cache_files().map(|(files, _)| sum_sizes(&files)).unwrap_or(0) + } } /// What the grants file of a plug-in store holds. diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index a94696d16..f686d80dc 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -3012,6 +3012,42 @@ fn a_trace_run_hands_its_trace_to_the_command() { assert!((bb.center().x - 160.0).abs() < 1.5, "traced where the image is now: {bb:?}"); } +#[test] +fn image_trace_ignore_background_keeps_the_color_inside_the_art() { + // A black field, a light panel on it and a black ring on the panel. + let art = vectorcraft_trace::Raster::from_fn(200, 150, |x, y| { + let r = (f64::from(x) + 0.5 - 100.0).hypot(f64::from(y) + 0.5 - 75.0); + if !((20..180).contains(&x) && (20..130).contains(&y)) || (37.0..=43.0).contains(&r) { [0, 0, 0, 255] } else { [240, 240, 220, 255] } + }); + let traced = |background: bool| { + let mut s = session(); + add_image(&mut s, &art, Affine::IDENTITY); + let params = json!({"params": {"mode": "color", "palette": "limited", "colors": 2, "ignoreWhite": true, "ignoreColor": "#000000", "ignoreBackground": background}}); + let r = s.execute("imageTrace.makeAndExpand", ¶ms).unwrap(); + let g = node(&s, NodeId(r["id"].as_u64().unwrap())); + let black: Vec<_> = g + .children() + .unwrap() + .iter() + .filter(|n| n.appearance.fill_paint() == Paint::solid(Color::rgb8(0, 0, 0))) + .map(|n| outline(n).0.bounds().unwrap()) + .collect(); + (r["colors"].clone(), black) + }; + // The whole color: the ring goes with the field. + let (colors, black) = traced(false); + assert_eq!(colors, 1); + assert!(black.is_empty(), "{black:?}"); + // The background only: the ring stays, and nothing black reaches the image's edge. + let (colors, black) = traced(true); + assert_eq!(colors, 2); + assert_eq!(black.len(), 1, "{black:?}"); + let bb = black[0]; + assert!((bb.x0 - 57.0).abs() < 1.5 && (bb.x1 - 143.0).abs() < 1.5 && (bb.y0 - 32.0).abs() < 1.5 && (bb.y1 - 118.0).abs() < 1.5, "{bb:?}"); + // The params doc tells agents about the option. + assert!(find_command("imageTrace.make").unwrap().params.contains("ignoreBackground: false")); +} + /// A Document Library trace made away from its command no longer applies once the library's /// colours change: the settings' JSON leaves them out (#4605 review). #[test] diff --git a/crates/engine/src/tests/colormgmt.rs b/crates/engine/src/tests/colormgmt.rs index f841efedc..d0901f30a 100644 --- a/crates/engine/src/tests/colormgmt.rs +++ b/crates/engine/src/tests/colormgmt.rs @@ -374,7 +374,7 @@ fn rgb_art_separates_with_gcr() { RenderOptions { proof: Some(ProofSetup { separations: Some(vec!["Black".into()]), ..Default::default() }), ..Default::default() }, ) .pixel(50, 50); - assert!(k[0] < 20, "RGB black is mostly K: {k:?}"); + assert!((20..=30).contains(&k[0]), "RGB black carries 90% black: {k:?}"); let c = render( &doc, RenderOptions { proof: Some(ProofSetup { separations: Some(vec!["Cyan".into()]), ..Default::default() }), ..Default::default() }, diff --git a/crates/engine/src/tests/docsetup.rs b/crates/engine/src/tests/docsetup.rs index c1c70ac07..cf2e4a510 100644 --- a/crates/engine/src/tests/docsetup.rs +++ b/crates/engine/src/tests/docsetup.rs @@ -28,16 +28,28 @@ fn create_text(s: &mut Session, t: &str) -> NodeId { NodeId(s.execute("text.create", &json!({"x": 10, "y": 50, "text": t, "size": 20})).unwrap()["id"].as_u64().unwrap()) } -/// Click with the Type tool at (x, y) and type `keys` one at a time; returns the new text object. +/// Click with the Type tool at (x, y) and type `keys` one at a time on the keyboard; returns the +/// new text object. fn type_at(s: &mut Session, x: f64, y: f64, keys: &[&str]) -> NodeId { + enter_at(s, x, y, keys, true) +} + +/// Click with the Type tool at (x, y) and enter `texts` one at a time, typed on the keyboard +/// ([`Session::tool_typed_text`]) or inserted as the Glyphs panel and pastes do +/// ([`Session::tool_text`]); returns the new text object. +fn enter_at(s: &mut Session, x: f64, y: f64, texts: &[&str], keyboard: bool) -> NodeId { let v = ViewInfo::default(); s.select_tool("type", v).unwrap(); for k in [PointerKind::Down, PointerKind::Up] { s.pointer(&PointerEvent::new(k, x, y), v).unwrap(); } let id = s.doc().unwrap().selection.objects[0]; - for k in keys { - s.tool_text(k, v).unwrap(); + for t in texts { + if keyboard { + s.tool_typed_text(t, v).unwrap(); + } else { + s.tool_text(t, v).unwrap(); + } } s.tool_key(ToolKey::Escape, Default::default(), v).unwrap(); id @@ -177,6 +189,53 @@ fn typographers_quotes_setting_changes_typed_quotes() { assert_eq!(text(&s, id).plain_text(), "\"b'"); } +/// A lone typed `”` is set like a straight quote where a quote opens: the macOS Serbian Cyrillic +/// layout has no straight quote and types `”` on Shift+2 (#5247), so Serbian text still gets +/// „…”. Where a quote closes it stays `”`. `“ „` and the single quotes stay as typed, so `’90s` +/// keeps its apostrophe. Pasted text and the Glyphs panel keep their quotes, and with the option +/// off `”` stays as typed. +#[test] +fn a_typed_closing_double_quote_opens_where_a_quote_opens() { + let mut s = session(); + s.execute("document.setup", &json!({"language": "Serbian: Cyrillic"})).unwrap(); + let id = type_at(&mut s, 50.0, 50.0, &["”", "П", "р", "о", "б", "а", "”"]); + assert_eq!(text(&s, id).plain_text(), "„Проба”"); + // The other curly quotes stay as typed. + let id = type_at(&mut s, 50.0, 100.0, &["„", "a", " ", "’", "b", "’", "“"]); + assert_eq!(text(&s, id).plain_text(), "„a ’b’“"); + // In English a `”` that opens is `“`; the apostrophes stay. + s.execute("document.setup", &json!({"language": "English: USA"})).unwrap(); + let id = type_at(&mut s, 50.0, 150.0, &["”", "i", "t", "’", "s", "”"]); + assert_eq!(text(&s, id).plain_text(), "“it’s”"); + // An apostrophe after a space stays an apostrophe, and a typed `‘` stays `‘`. + let id = type_at(&mut s, 50.0, 200.0, &["t", "h", "e", " ", "’", "9", "0", "s", " ", "‘", "a"]); + assert_eq!(text(&s, id).plain_text(), "the ’90s ‘a"); + // Where a quote closes, `”` stays `”` even if the document's closing quote is another. + s.execute("document.setup", &json!({"language": "German"})).unwrap(); + let id = type_at(&mut s, 50.0, 250.0, &["”", "a", "”"]); + assert_eq!(text(&s, id).plain_text(), "„a”"); + // More than one character is pasted text, not a typed quote. + let id = type_at(&mut s, 50.0, 300.0, &["”a”"]); + assert_eq!(text(&s, id).plain_text(), "”a”"); + // An IME commit of a lone `”` that will open is not verbatim (it is rewritten); from the Glyphs + // panel it would be inserted as it is. + let v = ViewInfo::default(); + s.select_tool("type", v).unwrap(); + for k in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(k, 50.0, 312.0), v).unwrap(); + } + assert!(!s.tool_typed_text_is_verbatim("”", v)); + assert!(s.tool_text_is_verbatim("”", v)); + s.tool_key(ToolKey::Escape, Default::default(), v).unwrap(); + // A `”` inserted from the Glyphs panel or pasted alone stays as inserted. + let id = enter_at(&mut s, 50.0, 325.0, &["”", "a"], false); + assert_eq!(text(&s, id).plain_text(), "”a"); + // With the option off a typed `”` stays as typed. + s.execute("document.setup", &json!({"typographersQuotes": false})).unwrap(); + let id = type_at(&mut s, 50.0, 350.0, &["”", "b", "”"]); + assert_eq!(text(&s, id).plain_text(), "”b”"); +} + #[test] fn smart_punctuation_uses_the_document_quotes() { let mut s = session(); diff --git a/crates/engine/src/tests/draw2.rs b/crates/engine/src/tests/draw2.rs index 1af4ac40f..f6902e7a9 100644 --- a/crates/engine/src/tests/draw2.rs +++ b/crates/engine/src/tests/draw2.rs @@ -2673,6 +2673,87 @@ fn pen_with_cmd_borrows_direct_selection_and_goes_on_drawing() { assert_eq!(s.tool_id(), "rectangle"); } +/// #6123: anchors that Cmd (Ctrl) held with the Pen selects with the Direct Selection it lends are +/// what Copy and Cut take, as with Direct Selection chosen; the last anchor the Pen leaves selected +/// while drawing is not, Copy taking the whole path (#2122), nor after a Cmd gesture that picks +/// nothing. +#[test] +fn pen_cmd_selected_anchors_are_what_copy_and_cut_take() { + let (none, cmd) = (Mods::default(), Mods { cmd: true, ..Mods::default() }); + let v = view(); + let mut s = session(); + s.select_tool("pen", v).unwrap(); + for x in [100.0, 200.0, 300.0, 400.0] { + gesture(&mut s, &[(x, 300.0)], none); + } + let id = s.doc().unwrap().selection.objects[0]; + let pasted = |s: &mut Session| { + let ids = s.execute("edit.paste", &json!({})).unwrap()["ids"].clone(); + let ids: Vec = ids.as_array().unwrap().iter().map(|i| NodeId(i.as_u64().unwrap())).collect(); + ids.iter().map(|i| path(s, *i).subpaths[0].anchors.len()).collect::>() + }; + // A Cmd marquee around the middle two anchors: Copy takes them and the segment between. + gesture(&mut s, &[(150.0, 250.0), (350.0, 350.0)], cmd); + assert_eq!(s.tool_id(), "pen"); + assert_eq!(s.doc().unwrap().selection.anchors[&id], [(0, 1), (0, 2)].into()); + s.execute("edit.copy", &json!({})).unwrap(); + assert_eq!(pasted(&mut s), [2]); + // Cut takes the segment between them out of the path, as with Direct Selection chosen. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.cut", &json!({})).unwrap(); + let left = path(&s, id).subpaths.iter().map(|sp| sp.anchors.len()).collect::>(); + assert_eq!((s.clipboard.nodes.len(), left), (1, vec![2, 2])); + // Drawing on: the last anchor the Pen leaves selected doesn't make Copy take that anchor. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + gesture(&mut s, &[(500.0, 300.0)], none); + assert_eq!(s.doc().unwrap().selection.anchors[&id], [(0, 4)].into()); + s.execute("edit.copy", &json!({})).unwrap(); + assert_eq!(pasted(&mut s), [5]); + // A Cmd+Shift marquee over nothing picks nothing: the Pen's last anchor stays its own. + s.execute("edit.undo", &json!({})).unwrap(); + let shift_cmd = Mods { shift: true, ..cmd }; + gesture(&mut s, &[(600.0, 500.0), (700.0, 600.0)], shift_cmd); + assert_eq!(s.doc().unwrap().selection.anchors[&id], [(0, 4)].into()); + s.execute("edit.copy", &json!({})).unwrap(); + assert_eq!(pasted(&mut s), [5]); + // The tool started afresh in the same document (another view of it) keeps a pick picked. + s.execute("edit.undo", &json!({})).unwrap(); + gesture(&mut s, &[(150.0, 250.0), (350.0, 350.0)], cmd); + s.reset_tool(); + s.execute("edit.copy", &json!({})).unwrap(); + assert_eq!(pasted(&mut s), [2]); +} + +/// #6123: with the other tools, Cmd (Ctrl) lends the selection tool chosen last. Direct Selection +/// lent picks anchors, which Copy takes, as with the Curvature tool, which Cmd lends Direct +/// Selection until a selection tool is chosen; the Selection tool lent selects the path whole. +#[test] +fn cmd_selected_anchors_with_other_tools_are_what_copy_takes() { + let cmd = Mods { cmd: true, ..Mods::default() }; + let v = view(); + let copied = |tool: &str, last_selection: Option<&str>| { + let mut s = session(); + let anchors = json!([{"x": 100, "y": 300}, {"x": 200, "y": 300}, {"x": 300, "y": 300}, {"x": 400, "y": 300}]); + s.execute("path.create", &json!({"anchors": anchors})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + if let Some(t) = last_selection { + s.select_tool(t, v).unwrap(); + } + s.select_tool(tool, v).unwrap(); + gesture(&mut s, &[(150.0, 250.0), (350.0, 350.0)], cmd); + assert_eq!(s.tool_id(), tool); + s.execute("edit.copy", &json!({})).unwrap(); + let ids = s.execute("edit.paste", &json!({})).unwrap()["ids"].clone(); + let id = NodeId(ids[0].as_u64().unwrap()); + path(&s, id).subpaths[0].anchors.len() + }; + assert_eq!(copied("rectangle", Some("directSelection")), 2); + assert_eq!(copied("ellipse", Some("directSelection")), 2); + assert_eq!(copied("curvature", None), 2); + assert_eq!(copied("rectangle", None), 4, "the Selection tool lent selects the path whole"); +} + /// #4980: Cmd/Ctrl held with the Pen drags as Direct Selection: an anchor of the path being drawn /// moves on its own and the Pen goes on drawing that path from it; another path's straight segment /// moves both its ends and a curved one bends; each drag is one undo step and the pointer is the diff --git a/crates/engine/src/tests/dynamic_symbols.rs b/crates/engine/src/tests/dynamic_symbols.rs index 9ec00cd24..b946f7192 100644 --- a/crates/engine/src/tests/dynamic_symbols.rs +++ b/crates/engine/src/tests/dynamic_symbols.rs @@ -4,6 +4,7 @@ //! Fill or Stroke changes it. use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Node, NodeKind}; use vectorcraft_geom::Rect; use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; @@ -107,6 +108,314 @@ fn static_symbols_and_bad_targets_are_refused() { assert_eq!(overrides(&s, inst), 0); } +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +/// The frontmost object of the active layer. +fn top(s: &Session) -> NodeId { + let d = &s.doc().unwrap().doc; + d.node(d.default_layer().unwrap()).unwrap().children().unwrap().last().unwrap().id +} + +/// Every pixel of the page. +fn page(s: &Session) -> Vec<[u8; 4]> { + let r = vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 200.0, 100.0), 1.0, true); + (0..r.height).flat_map(|y| (0..r.width).map(move |x| (x, y))).map(|(x, y)| r.pixel(x, y)).collect() +} + +/// A dynamic symbol "Art" of the frontmost object, made in place: its instance. +fn art_of_top(s: &mut Session) -> NodeId { + let t = top(s); + id(&s.execute("symbol.new", &json!({"name": "Art", "ids": [t.0], "type": "dynamic"})).unwrap()) +} + +/// Put a red fill and stroke on the root of instance `inst`'s art directly, past the command. +fn paint_root_red(s: &mut Session, inst: NodeId) { + let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0)); + s.edit("paint", |d, _| { + let o = vectorcraft_doc::symbol_overrides::SymbolOverride { path: vec![], fill: Some(red.clone()), stroke: Some(red), ..Default::default() }; + d.node_mut(inst).ok_or(EngineError::NoNode(inst))?.extras_mut().symbol_overrides.push(o); + Ok(()) + }) + .unwrap(); +} + +/// A 30×30 red image placed with its frame from (50, 20) to (80, 50). +fn place_image(s: &mut Session) { + let png = crate::tests_links::png(30, 30, [255, 0, 0]); + let data = vectorcraft_format::base64_encode(&png); + s.execute("file.place", &json!({"name": "red.png", "dataBase64": data, "at": [65, 35], "link": false})).unwrap(); +} + +fn select_top(s: &mut Session, n: usize) { + let d = &s.doc().unwrap().doc; + let kids = d.node(d.default_layer().unwrap()).unwrap().children().unwrap(); + let ids: Vec = kids[kids.len() - n..].iter().map(|c| c.id.0).collect(); + s.execute("select.set", &json!({ "ids": ids })).unwrap(); +} + +#[test] +fn objects_that_draw_no_fill_or_stroke_of_their_own_refuse_one() { + let folder = crate::tests_links::Folder::new("dynamic-symbol-kinds"); + let source = folder.file("placed.vectorcraft"); + let mut doc = vectorcraft_doc::Document::new(40.0, 40.0); + let layer = doc.layers[0].id; + let look = vectorcraft_doc::Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0); + let rect = vectorcraft_doc::Node::path(doc.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 40.0)), look); + doc.insert(Some(layer), 0, rect).unwrap(); + crate::tests_links::write(&source, &vectorcraft_format::save_file(&doc)); + type Make = Box; + let cases: Vec<(&str, &str, Make)> = vec![ + ( + "guide", + "a guide", + Box::new(|s| { + square(s, 10.0); + select_top(s, 1); + s.execute("view.guides.make", &json!({})).unwrap(); + }), + ), + ( + "blend", + "a blend", + Box::new(|s| { + let (a, b) = (square(s, 10.0), square(s, 60.0)); + s.execute("object.blend.make", &json!({"ids": [a.0, b.0], "steps": 1})).unwrap(); + }), + ), + ( + "envelope", + "an envelope", + Box::new(|s| { + square(s, 10.0); + select_top(s, 1); + s.execute("object.envelope.makeWithWarp", &json!({"style": "arch", "bend": 50})).unwrap(); + }), + ), + ("radial Repeat", "a Repeat", Box::new(|s| repeat(s, "object.repeat.radial"))), + ("grid Repeat", "a Repeat", Box::new(|s| repeat(s, "object.repeat.grid"))), + ("mirror Repeat", "a Repeat", Box::new(|s| repeat(s, "object.repeat.mirror"))), + ( + "Intertwine", + "an Intertwine group", + Box::new(|s| { + square(s, 10.0); + square(s, 30.0); + select_top(s, 2); + s.execute("object.intertwine.make", &json!({})).unwrap(); + }), + ), + ( + "gradient mesh", + "a gradient mesh", + Box::new(|s| { + let r = square(s, 10.0); + s.execute("object.mesh.create", &json!({"ids": [r.0], "rows": 1, "cols": 1})).unwrap(); + }), + ), + ("image", "an image", Box::new(place_image)), + ( + "placed document", + "a placed document", + Box::new(move |s| { + let r = s.execute("file.place", &json!({"path": source, "at": [40, 40]})).unwrap(); + assert_eq!(r["linked"], true, "{r}"); + }), + ), + ]; + // Every case runs, and the failure lists each object that fails. + let mut failures = Vec::new(); + for (label, kind, make) in cases { + let mut s = session(); + make(&mut s); + let inst = art_of_top(&mut s); + if let Err(why) = refuses_own_paint(&mut s, inst, kind) { + failures.push(format!("{label}: {why}")); + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +/// Check that the root of instance `inst`'s art, an object of `kind` that draws no fill or stroke +/// of its own, refuses both, takes null, opacity and blend, and draws no stored fill or stroke. +fn refuses_own_paint(s: &mut Session, inst: NodeId, kind: &str) -> std::result::Result<(), String> { + let set = |s: &mut Session, p: &Value| s.execute("symbol.instance.setAppearance", p); + let undo = undo_len(s); + for key in ["fill", "stroke"] { + for paint in [json!("#ff0000"), json!({"none": true})] { + // With a valid opacity too: nothing of the call is applied. + let p = json!({"id": inst.0, "path": [], key: paint, "opacity": 50}); + match set(s, &p) { + Ok(_) => return Err(format!("{p} was taken")), + Err(e) if !e.to_string().contains(&format!("{key} cannot be set on {kind}")) => return Err(format!("{p}: {e}")), + Err(_) => {} + } + } + } + if (overrides(s, inst), undo_len(s)) != (0, undo) { + return Err("a refused call changed the instance or added an undo step".into()); + } + // Null (taking a change back), opacity and blend are taken. + let clear = json!({"id": inst.0, "path": [], "fill": null, "stroke": null}); + for p in [clear, json!({"id": inst.0, "path": [], "opacity": 50, "blend": "multiply"})] { + set(s, &p).map_err(|e| format!("{p}: {e}"))?; + } + if overrides(s, inst) != 1 { + return Err("opacity and blend were not stored".into()); + } + // The renderer draws no fill or stroke of the object's own: a stored one changes nothing. + let before = page(s); + paint_root_red(s, inst); + if page(s) != before { + return Err("a stored fill and stroke changed the canvas".into()); + } + Ok(()) +} + +fn repeat(s: &mut Session, cmd: &str) { + square(s, 10.0); + select_top(s, 1); + s.execute(cmd, &json!({})).unwrap(); +} + +#[test] +fn a_symbol_instance_in_the_art_takes_a_fill_that_stains_it_and_no_stroke() { + let mut s = session(); + pair(&mut s, "static"); + let inst = art_of_top(&mut s); + let undo = undo_len(&s); + let e = s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [], "stroke": "#ff0000"})).unwrap_err().to_string(); + assert!(e.contains("stroke cannot be set on a symbol instance"), "{e}"); + assert_eq!((overrides(&s, inst), undo_len(&s)), (0, undo)); + // A stroke stored past the command draws nothing. + let before = page(&s); + let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0)); + s.edit("paint", |d, _| { + let o = vectorcraft_doc::symbol_overrides::SymbolOverride { path: vec![], stroke: Some(red), ..Default::default() }; + d.node_mut(inst).ok_or(EngineError::NoNode(inst))?.extras_mut().symbol_overrides.push(o); + Ok(()) + }) + .unwrap(); + assert!(page(&s) == before, "a stroke on a symbol instance draws nothing"); + // The fill stains the nested instance's art. + s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [], "fill": "#ff0000"})).unwrap(); + assert_eq!(pixel(&s, 30, 30), RED, "the fill stains the art"); +} + +#[test] +fn a_symbol_instance_in_the_art_refuses_a_gradient_fill() { + let mut s = session(); + pair(&mut s, "static"); + let inst = art_of_top(&mut s); + let undo = undo_len(&s); + let gradient = json!({"stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#00ff00"}]}); + let p = json!({"id": inst.0, "path": [], "fill": {"gradient": gradient}, "opacity": 50}); + let e = s.execute("symbol.instance.setAppearance", &p).unwrap_err().to_string(); + assert!(e.contains("fill on a symbol instance must be a solid color or none"), "{e}"); + assert_eq!((overrides(&s, inst), undo_len(&s)), (0, undo), "nothing changes and no undo step"); + // A gradient fill stored past the command draws nothing. + let before = page(&s); + let paint = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default()))); + s.edit("paint", |d, _| { + let o = vectorcraft_doc::symbol_overrides::SymbolOverride { path: vec![], fill: Some(paint), ..Default::default() }; + d.node_mut(inst).ok_or(EngineError::NoNode(inst))?.extras_mut().symbol_overrides.push(o); + Ok(()) + }) + .unwrap(); + assert!(page(&s) == before, "a gradient fill on a symbol instance draws nothing"); + // A solid fill and none are taken. + s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [], "fill": {"none": true}})).unwrap(); + s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [], "fill": "#ff0000"})).unwrap(); + assert_eq!(pixel(&s, 30, 30), RED); +} + +#[test] +fn a_symbol_instance_in_the_art_refuses_a_pattern_fill() { + let mut s = session(); + let tile = id(&s.execute("shape.rectangle", &json!({"x": 150, "y": 60, "width": 10, "height": 10})).unwrap()); + s.execute("object.pattern.make", &json!({"ids": [tile.0], "name": "Dots", "edit": false})).unwrap(); + pair(&mut s, "static"); + let inst = art_of_top(&mut s); + let undo = undo_len(&s); + let p = json!({"id": inst.0, "path": [], "fill": {"swatch": "Dots"}, "opacity": 50}); + let e = s.execute("symbol.instance.setAppearance", &p).unwrap_err().to_string(); + assert!(e.contains("fill on a symbol instance must be a solid color or none"), "{e}"); + assert_eq!((overrides(&s, inst), undo_len(&s)), (0, undo), "nothing changes and no undo step"); + // The pattern draws on a path. + let probe = square(&mut s, 110.0); + let before = page(&s); + s.execute("paint.setFill", &json!({"ids": [probe.0], "swatch": "Dots"})).unwrap(); + assert!(page(&s) != before, "the pattern draws on a path"); + // A pattern fill stored on the nested instance past the command draws nothing. + let before = page(&s); + let paint = s.doc().unwrap().doc.swatch("Dots").unwrap().paint.clone(); + assert!(matches!(paint, Paint::Pattern { .. }), "{paint:?}"); + s.edit("paint", |d, _| { + let o = vectorcraft_doc::symbol_overrides::SymbolOverride { path: vec![], fill: Some(paint), ..Default::default() }; + d.node_mut(inst).ok_or(EngineError::NoNode(inst))?.extras_mut().symbol_overrides.push(o); + Ok(()) + }) + .unwrap(); + assert!(page(&s) == before, "a pattern fill on a symbol instance draws nothing"); +} + +#[test] +fn null_takes_back_an_old_change_an_object_does_not_draw() { + let mut s = session(); + let (a, b) = (square(&mut s, 10.0), square(&mut s, 60.0)); + s.execute("object.blend.make", &json!({"ids": [a.0, b.0], "steps": 1})).unwrap(); + let inst = art_of_top(&mut s); + // A change stored before the command refused one, as an older document may hold. + paint_root_red(&mut s, inst); + s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [], "fill": null})).unwrap(); + assert!(node(&s, inst).extras().symbol_overrides[0].fill.is_none()); + s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [], "stroke": null})).unwrap(); + assert_eq!(overrides(&s, inst), 0); +} + +#[test] +fn paths_type_groups_and_shaped_image_paint_take_a_fill_and_stroke() { + let set = |s: &mut Session, inst: NodeId, path: Value| { + s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": path, "fill": "#ff0000", "stroke": "#ff0000"})).unwrap() + }; + // A group's own fill paints over its members. + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + assert_eq!(set(&mut s, inst, json!([]))["overrides"], 1); + assert_eq!(pixel(&s, 30, 30), RED, "the group's own fill"); + // A compound path, type and a clipping path. + let mut s = session(); + let (a, b) = (square(&mut s, 10.0), square(&mut s, 60.0)); + s.execute("object.compoundPath.make", &json!({"ids": [a.0, b.0]})).unwrap(); + let inst = art_of_top(&mut s); + set(&mut s, inst, json!([])); + assert_eq!(pixel(&s, 30, 30), RED, "the compound path"); + let mut s = session(); + s.execute("text.create", &json!({"x": 10, "y": 40, "text": "Hi"})).unwrap(); + let inst = art_of_top(&mut s); + set(&mut s, inst, json!([])); + let mut s = session(); + let (a, b) = (square(&mut s, 10.0), square(&mut s, 20.0)); + s.execute("object.clippingMask.make", &json!({"ids": [a.0, b.0]})).unwrap(); + let inst = art_of_top(&mut s); + assert!(matches!(&node(&s, inst).kind, NodeKind::SymbolInstance { .. })); + set(&mut s, inst, json!([0])); + set(&mut s, inst, json!([])); + // An image whose fill has Convert to Shape: the fill paints 9 pt past its frame. + let mut s = session(); + place_image(&mut s); + s.execute("appearance.addFill", &json!({})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#00ff00", "item": 0})).unwrap(); + s.execute("effect.apply", &json!({"effect": "convertToShape.rectangle", "item": 0})).unwrap(); + assert_eq!(pixel(&s, 44, 35), [0, 255, 0, 255]); + let inst = art_of_top(&mut s); + let e = s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [], "stroke": "#ff0000"})).unwrap_err().to_string(); + assert!(e.contains("stroke cannot be set on an image without a Convert to Shape or Outline Object effect on it"), "{e}"); + s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [], "fill": "#ff0000"})).unwrap(); + assert_eq!(pixel(&s, 44, 35), RED, "the shaped fill takes the instance's color"); +} + #[test] fn redefining_the_symbol_keeps_the_changes_whose_objects_remain() { let mut s = session(); diff --git a/crates/engine/src/tests/eyedropper.rs b/crates/engine/src/tests/eyedropper.rs index 786c492da..f8363fe93 100644 --- a/crates/engine/src/tests/eyedropper.rs +++ b/crates/engine/src/tests/eyedropper.rs @@ -333,8 +333,8 @@ fn an_rgb_image_sampled_in_a_cmyk_document_separates_through_the_color_settings( let got = node(&s, dst).appearance.fill_paint().color().unwrap(); assert_eq!(got, managed_cmyk(Color::rgb8(83, 170, 223))); let Color::Cmyk { c, m, y, k } = got else { panic!("{got:?}") }; - // Generic CMYK: about C59 M12 Y3 K0, with no black, where the formula gave K13. - assert!((0.5..0.75).contains(&c) && (0.05..0.2).contains(&m) && y < 0.05 && k < 0.02, "{c} {m} {y} {k}"); + // Generic CMYK: about C62 M19 Y0 K0, with no black, where the formula gave K13. + assert!((0.5..0.75).contains(&c) && (0.1..0.25).contains(&m) && y < 0.05 && k < 0.02, "{c} {m} {y} {k}"); // A light blue shows close to the color it was picked from; the formula's C12 M7 showed duller. click(&mut s, 450.0, 50.0, Mods::default()); let got = node(&s, dst).appearance.fill_paint().color().unwrap(); diff --git a/crates/engine/src/tests/fontlist.rs b/crates/engine/src/tests/fontlist.rs index d0804b7d0..6202a8ce7 100644 --- a/crates/engine/src/tests/fontlist.rs +++ b/crates/engine/src/tests/fontlist.rs @@ -14,7 +14,11 @@ fn names(v: &Value) -> Vec { fn the_font_list_names_every_available_family_with_its_styles() { let mut s = Session::new(); let fams = names(&s.execute("text.fontList", &json!({})).unwrap()["families"]); - assert_eq!(fams, *FontDb::global().menu_family_list()); + // Other tests running at the same time add and remove composite fonts, which the lists show + // first; the installed families are compared. + let db = FontDb::global(); + let installed_only = |list: &[String]| list.iter().filter(|f| db.has_installed_family(f)).cloned().collect::>(); + assert_eq!(installed_only(&fams), installed_only(&db.menu_family_list())); for f in ["Source Sans 3", "Source Serif 4", "Inter", "JetBrains Mono"] { assert!(fams.iter().any(|x| x == f), "{f}"); } diff --git a/crates/engine/src/tests/history_document.rs b/crates/engine/src/tests/history_document.rs index 0c78b1109..3c2c9b9eb 100644 --- a/crates/engine/src/tests/history_document.rs +++ b/crates/engine/src/tests/history_document.rs @@ -1,5 +1,6 @@ //! History panel › New Document from State (`edit.newDocumentFromState`). +use std::path::Path; use std::sync::Arc; use serde_json::{Value, json}; @@ -262,7 +263,72 @@ fn a_file_the_source_finds_in_its_folder_is_found_from_the_new_document() { s.execute(CMD, &json!({})).unwrap(); assert_eq!(s.active_index(), Some(1), "the new document"); let c = check(&mut s); - assert_eq!((&c["links"][0]["status"], &c["links"][0]["path"]), (&json!("ok"), &json!(moved)), "{c}"); + assert_eq!((&c["links"][0]["status"], c["links"][0]["path"].as_str().map(Path::new)), (&json!("ok"), Some(Path::new(&moved))), "{c}"); +} + +/// A linked file missing from the moved source keeps the place its relative path names from the +/// source's folder: the new document, saved beside the source, writes the same relative path. +#[test] +fn a_missing_file_keeps_its_relative_path_in_the_new_document() { + let a = Folder::new("history-document-missing-a"); + let b = Folder::new("history-document-missing-b"); + let away = Folder::new("history-document-missing-away"); + write(&a.file("art/photo.png"), &png(60, 30, RED)); + let mut s = session(); + place(&mut s, &a.file("art/photo.png")); + s.execute("document.save", &json!({"path": a.file("poster.vectorcraft")})).unwrap(); + std::fs::remove_dir_all(&b.0).unwrap(); + std::fs::rename(&a.0, &b.0).unwrap(); + std::fs::rename(b.0.join("art"), away.0.join("art")).unwrap(); + let r = s.execute("document.open", &json!({"path": b.file("poster.vectorcraft")})).unwrap(); + assert_eq!(r["missingLinks"][0]["name"], "photo.png", "{r}"); + s.execute(CMD, &json!({})).unwrap(); + let copy = b.file("copy.vectorcraft"); + s.execute("document.save", &json!({"path": copy})).unwrap(); + let saved: Value = serde_json::from_slice(&std::fs::read(©).unwrap()).unwrap(); + let link = &saved["document"]["layers"][0]["kind"]["children"][0]["kind"]["link"]; + assert_eq!(link["relative"].as_str(), Some("art/photo.png"), "{link}"); +} + +/// A linked file that comes back beside the document the source was read from, after the source +/// was saved in another folder, is found from the new document. +#[test] +fn a_file_back_beside_the_source_it_was_read_from_is_found_from_the_new_document() { + let a = Folder::new("history-document-back-a"); + let b = Folder::new("history-document-back-b"); + let away = Folder::new("history-document-back-away"); + write(&a.file("art/photo.png"), &png(60, 30, RED)); + let mut s = session(); + place(&mut s, &a.file("art/photo.png")); + s.execute("document.save", &json!({"path": a.file("poster.vectorcraft")})).unwrap(); + std::fs::remove_dir_all(&b.0).unwrap(); + std::fs::rename(&a.0, &b.0).unwrap(); + std::fs::rename(b.0.join("art"), away.0.join("art")).unwrap(); + s.execute("document.open", &json!({"path": b.file("poster.vectorcraft")})).unwrap(); + std::fs::create_dir_all(b.0.join("saved")).unwrap(); + s.execute("document.save", &json!({"path": b.file("saved/poster.vectorcraft")})).unwrap(); + std::fs::rename(away.0.join("art"), b.0.join("art")).unwrap(); + let source = s.active_index().unwrap(); + s.execute(CMD, &json!({})).unwrap(); + let c = s.execute("links.check", &json!({})).unwrap(); + let moved = b.file("art/photo.png"); + assert_eq!((&c["links"][0]["status"], c["links"][0]["path"].as_str().map(Path::new)), (&json!("ok"), Some(Path::new(&moved))), "{c}"); + // A file beside the folder the source was last saved in is found too. + std::fs::create_dir_all(b.0.join("saved/art")).unwrap(); + std::fs::rename(&moved, b.file("saved/art/photo.png")).unwrap(); + s.execute("document.activate", &json!({"index": source})).unwrap(); + s.execute(CMD, &json!({})).unwrap(); + let c = s.execute("links.check", &json!({})).unwrap(); + let saved = b.file("saved/art/photo.png"); + assert_eq!((&c["links"][0]["status"], c["links"][0]["path"].as_str().map(Path::new)), (&json!("ok"), Some(Path::new(&saved))), "{c}"); + // Missing again when the new document is saved, the file keeps the place it was found. + std::fs::rename(&saved, away.file("photo.png")).unwrap(); + std::fs::create_dir_all(b.0.join("new")).unwrap(); + let copy = b.file("new/copy.vectorcraft"); + s.execute("document.save", &json!({"path": copy})).unwrap(); + let file: Value = serde_json::from_slice(&std::fs::read(©).unwrap()).unwrap(); + let link = &file["document"]["layers"][0]["kind"]["children"][0]["kind"]["link"]; + assert_eq!(link["relative"].as_str(), Some("../saved/art/photo.png"), "{link}"); } /// The same holds for a linked image only a symbol shows. diff --git a/crates/engine/src/tests/ids_without_selection.rs b/crates/engine/src/tests/ids_without_selection.rs index f8449c104..8e26577f9 100644 --- a/crates/engine/src/tests/ids_without_selection.rs +++ b/crates/engine/src/tests/ids_without_selection.rs @@ -135,8 +135,9 @@ fn id_does_what_ids_of_one_does_with_nothing_selected() { /// Every command that takes `id` as `ids` of one ([`cmd::params::id_stands_for_ids`]), with an /// object selected: an `id` that is malformed (a string, negative, a fraction, an object) or names -/// no object is refused under the command's id, as the same value in `ids` is, and the document -/// and the selection stay as they were. It never stands for the selection instead. (A string `id` +/// no object is refused under the command's id, as the same value in `ids` is (whose error for a +/// malformed entry also names the key and the entry), and the document and the selection stay as +/// they were. It never stands for the selection instead. (A string `id` /// given to `effect.apply` without `effect` names the effect, which "bad" isn't: refused too.) #[test] fn a_bad_id_is_refused_as_the_same_ids_are_and_changes_nothing() { @@ -158,7 +159,10 @@ fn a_bad_id_is_refused_as_the_same_ids_are_and_changes_nothing() { let st = s.doc().unwrap(); (r, st.doc == before && st.selection.objects == vec![NodeId(d)]) }; - let (with_id, with_ids) = (run("id", bad.clone()), run("ids", json!([bad.clone()]))); + let (with_id, mut with_ids) = (run("id", bad.clone()), run("ids", json!([bad.clone()]))); + // The error for an `ids` entry names the key and the entry; the one for `id` names the value. + let entry = format!("`ids` entry 0 is {bad}, not a non-negative integer object id"); + with_ids.0 = with_ids.0.map_err(|e| e.replace(&entry, &format!("invalid object id {bad}: expected a non-negative integer"))); let effect_named = spec.id == "effect.apply" && bad.is_string(); if with_id.0.is_ok() || !with_id.1 || (with_id != with_ids && !effect_named) { failures.push(format!( @@ -301,3 +305,205 @@ fn ids_of_an_object_and_a_group_that_holds_it_do_what_that_selection_does() { assert!(failures.is_empty(), "{} differ:\n{}", failures.len(), failures.join("\n")); assert!(succeeded >= 150, "only {succeeded} runs succeeded"); } + +/// What `ids` (or `slices`) left out or null gives a command: the selection, or the command's own +/// default. +enum Unset { + /// The selected objects: a result, the same as the list of the selection gives. + Selected, + /// The selected objects, which the command refuses with this error, as it refuses the list of + /// the selection. + SelectionRefused(&'static str), + /// This error, which the list of the selection does not give. + Fails(&'static str), + /// This result, which the list of the selection does not give. + Gives(serde_json::Value), +} + +/// The commands that read their `ids` (or `slices`) list themselves or pass it to a colour pass, +/// with the rectangle apart (`d`): the key, the other params, and what leaving the key out gives. +fn ids_cases(d: u64) -> Vec<(&'static str, &'static str, serde_json::Value, Unset)> { + use Unset::*; + vec![ + ("appearance.copyFrom", "ids", json!({ "source": d }), Selected), + ("edit.colors.invert", "ids", json!({}), Selected), + ("edit.colors.toCMYK", "ids", json!({}), Selected), + ("edit.colors.toGrayscale", "ids", json!({}), Selected), + ("edit.colors.toRGB", "ids", json!({}), Selected), + ("edit.colors.saturate", "ids", json!({"intensity": 50}), Selected), + ("edit.colors.adjustBalance", "ids", json!({"r": 20}), Selected), + ("paint.invert", "ids", json!({}), Selected), + ("paint.complement", "ids", json!({}), Selected), + ("addon.color.filter", "ids", json!({"effect": "addon.color.invert"}), Selected), + ("addon.color.applyNearestSwatch", "ids", json!({}), Selected), + ("edit.colors.overprintBlack", "ids", json!({}), Selected), + ("edit.findReplace", "ids", json!({"find": "A", "replace": "B"}), Gives(json!({"count": 1}))), + ("layer.highlight", "ids", json!({}), Fails("missing ids")), + ("layer.move", "ids", json!({ "target": d }), Fails("missing ids")), + ("library.add", "ids", json!({"kind": "graphic"}), Selected), + ("library.add", "ids", json!({"kind": "fillColor"}), Selected), + ("links.check", "ids", json!({}), Selected), + ("links.update", "ids", json!({}), Selected), + ("links.relink", "ids", json!({"path": "missing.png"}), Fails("select the images to relink, or pass ids")), + ("links.embed", "ids", json!({}), Fails("select the linked images or placed documents, or pass ids")), + ("links.placementOptions", "ids", json!({}), Fails("select the images, or pass ids")), + ("object.transform", "ids", json!({"matrix": [1, 0, 0, 1, 10, 0]}), Selected), + ("object.pattern.make", "ids", json!({"name": "P"}), Selected), + ("object.repeat.radial", "ids", json!({}), Selected), + ("object.repeat.grid", "ids", json!({}), Selected), + ("object.repeat.mirror", "ids", json!({}), Selected), + ("object.slice.make", "ids", json!({}), Selected), + ("object.slice.move", "slices", json!({"dx": 1, "dy": 1}), Selected), + ("object.slice.delete", "slices", json!({}), Selected), + ("object.slice.select", "slices", json!({}), Gives(json!({"selected": []}))), + ("path.join", "ids", json!({}), SelectionRefused("path is already closed")), + ("path.averageJoin", "ids", json!({"to": "corner"}), SelectionRefused("select two open path ends, or two open paths, to average and join")), + ("symbol.edit", "ids", json!({}), Fails("the document has no symbols")), + ("transparency.makeOpacityMask", "ids", json!({}), Selected), + ] +} + +/// What `cmd` does in a fresh [`doc_with_type`] with `selected` selected (made an object slice +/// first when `slice`): its result or error, the document, the selection and the undo history's +/// length. +fn outcome( + cmd: &str, + p: &serde_json::Value, + selected: &[u64], + slice: bool, +) -> (std::result::Result, vectorcraft_doc::Document, vectorcraft_doc::Selection, usize) { + let (mut s, ..) = doc_with_type(); + if slice { + s.execute("object.slice.make", &json!({ "ids": selected })).unwrap(); + } + s.execute("select.set", &json!({ "ids": selected })).unwrap(); + let r = s.execute(cmd, p).map_err(|e| e.to_string()); + let st = s.doc().unwrap(); + (r, (*st.doc).clone(), st.selection.clone(), st.history.undo.len()) +} + +/// With the rectangle apart (`d`) selected and with nothing selected, a list with an entry that is +/// not a non-negative integer (a string, true, an object) is refused with an error naming the key, +/// the entry and its value, a value that is not a list with an error naming the key, and the +/// document, the selection and the undo history stay as they were. No entry is dropped. A list of +/// the rectangle is not refused. +#[test] +fn an_ids_entry_that_is_not_an_id_is_refused_and_changes_nothing() { + let (_, [.., d], _) = doc_with_type(); + let mut failures = vec![]; + for (cmd, key, params, _) in &ids_cases(d) { + let bads = [json!(["abc"]), json!([d, true]), json!([{}]), json!("abc")]; + for (bad, selected) in bads.iter().flat_map(|b| [(b, true), (b, false)]) { + // A fresh document each time, so no mode one case enters carries into the next. + let (mut s, ..) = doc_with_type(); + if selected { + s.execute("select.set", &json!({ "ids": [d] })).unwrap(); + } + let st = s.doc().unwrap(); + let before = (st.doc.clone(), st.selection.clone(), st.history.undo.len()); + let mut p = params.clone(); + p[*key] = bad.clone(); + let r = s.execute(cmd, &p); + let want = match bad.as_array() { + Some(a) => { + let (index, value) = a.iter().enumerate().find(|(_, v)| !v.is_u64()).unwrap(); + format!("`{key}` entry {index} is {value}, not a non-negative integer object id") + } + None => format!("{key} must be an array of non-negative integer object ids"), + }; + match &r { + Err(EngineError::BadParams { cmd: c, msg }) if c == cmd && *msg == want => {} + r => failures.push(format!("{cmd} {key}: {bad} (selected: {selected}): {r:.160?}")), + } + let st = s.doc().unwrap(); + if (st.doc.clone(), st.selection.clone(), st.history.undo.len()) != before { + failures.push(format!("{cmd} {key}: {bad} (selected: {selected}): the document, the selection or the undo history changed")); + } + } + let (mut s, ..) = doc_with_type(); + s.execute("select.set", &json!({ "ids": [d] })).unwrap(); + let mut p = params.clone(); + p[*key] = json!([d]); + if let Err(EngineError::BadParams { msg, .. }) = s.execute(cmd, &p) + && (msg.starts_with(&format!("`{key}` entry ")) || msg.starts_with(&format!("{key} must be an array"))) + { + failures.push(format!("{cmd} {key}: [{d}] is refused: {msg}")); + } + } + assert!(failures.is_empty(), "{} fail:\n{}", failures.len(), failures.join("\n")); +} + +/// With the rectangle apart (`d`) selected, the same commands with `ids` (or `slices`) left out and +/// with it null do the same: what the selection's list does for the commands that take the +/// selection, and the command's own default for the others (`object.slice.select` deselects the +/// slices). `addon.color.filter` and `addon.color.applyNearestSwatch` read `ids` through +/// [`cmd::checked_ids_or_selection`], which refuses null as a value that is not a list and changes +/// nothing. +#[test] +fn ids_left_out_or_null_take_the_selection_or_the_default() { + let (_, [.., d], _) = doc_with_type(); + let mut failures = vec![]; + for (cmd, key, params, unset) in ids_cases(d) { + // The commands on `slices` act on the selected object once it is an object slice. + let slice = key == "slices"; + let left_out = outcome(cmd, ¶ms, &[d], slice); + let mut null = params.clone(); + null[key] = serde_json::Value::Null; + let with_null = outcome(cmd, &null, &[d], slice); + if matches!(cmd, "addon.color.filter" | "addon.color.applyNearestSwatch") { + let (mut s, ..) = doc_with_type(); + s.execute("select.set", &json!({ "ids": [d] })).unwrap(); + let st = s.doc().unwrap(); + let refused = with_null.0.as_ref().is_err_and(|e| e.ends_with("ids must be an array of non-negative integer object ids")); + if !refused || (&with_null.1, &with_null.2, with_null.3) != (&*st.doc, &st.selection, st.history.undo.len()) { + failures.push(format!("{cmd} {key}: null is not refused with nothing changed ({:.160?})", with_null.0)); + } + } else if with_null != left_out { + failures.push(format!("{cmd} {key}: null does not do what leaving it out does ({:.160?})", left_out.0)); + } + let mut listed = params.clone(); + listed[key] = json!([d]); + let listed = outcome(cmd, &listed, &[d], slice); + let ok = match &unset { + Unset::Selected => left_out.0.is_ok() && listed == left_out, + Unset::SelectionRefused(want) => left_out.0.as_ref().is_err_and(|e| e.ends_with(want)) && listed == left_out, + Unset::Fails(want) => left_out.0.as_ref().is_err_and(|e| e.ends_with(want)) && listed.0 != left_out.0, + Unset::Gives(want) => left_out.0.as_ref() == Ok(want) && listed.0 != left_out.0, + }; + if !ok { + failures.push(format!("{cmd} {key}: left out: {:.200?}; listed: {:.200?}", left_out.0, listed.0)); + } + } + assert!(failures.is_empty(), "{} fail:\n{}", failures.len(), failures.join("\n")); +} + +/// With no document open, every command that takes `ids` refuses a list with an entry that is not +/// a non-negative integer, and a value that is not a list, with the same error as with a document +/// open (`effect.list` and `links.findFiles` run without a document). +#[test] +fn an_ids_entry_that_is_not_an_id_is_refused_with_no_document() { + let (mut failures, mut checked) = (vec![], 0); + for spec in command_specs() { + if !cmd::params::takes_key(spec.params, "ids") { + continue; + } + checked += 1; + for bad in [json!(["abc"]), json!([1, true]), json!("abc")] { + let mut s = Session::new(); + let r = s.execute(spec.id, &json!({ "ids": bad })); + let want = match bad.as_array() { + Some(a) => { + let (index, value) = a.iter().enumerate().find(|(_, v)| !v.is_u64()).unwrap(); + format!("`ids` entry {index} is {value}, not a non-negative integer object id") + } + None => "ids must be an array of non-negative integer object ids".to_string(), + }; + match &r { + Err(EngineError::BadParams { cmd: c, msg }) if c == spec.id && *msg == want => {} + r => failures.push(format!("{} ids: {bad}: {r:.160?}", spec.id)), + } + } + } + assert!(failures.is_empty(), "{} fail:\n{}", failures.len(), failures.join("\n")); + assert!(checked >= 100, "only {checked} commands take ids"); +} diff --git a/crates/engine/src/tests/link_paths.rs b/crates/engine/src/tests/link_paths.rs index 76bd7168a..e39cf6689 100644 --- a/crates/engine/src/tests/link_paths.rs +++ b/crates/engine/src/tests/link_paths.rs @@ -85,6 +85,21 @@ fn linked_files_on_other_computers_are_never_read() { assert!(s.link_stamps.keys().all(|(_, p)| !far.contains(p)), "{:?}", s.link_stamps.keys()); } +/// A place a link names from the file the document was read from is followed only when the +/// document's own file follows it too: after a document read from a network share is saved on +/// this computer, its links are not looked for on that share. +#[test] +fn places_named_from_a_share_are_not_followed_after_a_save_elsewhere() { + use crate::cmd::links::follow_from; + let resolve = + |stored: &str, doc: Option<&str>| craft_docpath::resolve(stored, doc.map(std::path::Path::new)).map(|p| p.to_string_lossy().into_owned()); + let (link, on_share) = (r"\\server\share\a\art\photo.png", Some(r"\\server\share\a\poster.vectorcraft")); + assert_eq!(follow_from(link, on_share, on_share, resolve).as_deref(), Some(link)); + assert_eq!(follow_from(link, on_share, Some(r"\\server\share\b\poster.vectorcraft"), resolve).as_deref(), Some(link)); + assert_eq!(follow_from(link, on_share, Some("/local/b/poster.vectorcraft"), resolve), None); + assert_eq!(follow_from(link, on_share, Some(r"C:\b\poster.vectorcraft"), resolve), None); +} + /// The final path Windows gives a folder of the drive `Z:` mapped to `\\server\share`. The /// share's root folder has no trailing separator (`\\?\UNC\server\share`). fn on_share(folder: &str) -> Option { diff --git a/crates/engine/src/tests/links.rs b/crates/engine/src/tests/links.rs index cfe43ee8e..f0a0674f0 100644 --- a/crates/engine/src/tests/links.rs +++ b/crates/engine/src/tests/links.rs @@ -244,6 +244,141 @@ fn the_relative_path_finds_the_file_after_the_folder_moves() { assert!(near(centre_colour(&s.doc().unwrap().doc), RED)); } +/// A document moved to another folder while its linked file is missing keeps the file's relative +/// path when it is saved again, there or in a subfolder, so the file is found once it is back +/// beside the document. +#[test] +fn a_missing_link_keeps_its_relative_path_when_the_moved_document_is_saved() { + let a = Folder::new("missing-save-a"); + let b = Folder::new("missing-save-b"); + let away = Folder::new("missing-save-away"); + let pic = a.file("art/photo.png"); + write(&pic, &png(600, 300, RED)); + let mut s = session(); + let id = place(&mut s, &pic); + save(&mut s, &a.file("poster.vectorcraft")); + // Move the document and its links, then take the links away. + std::fs::remove_dir_all(&b.0).unwrap(); + std::fs::rename(&a.0, &b.0).unwrap(); + std::fs::rename(b.0.join("art"), away.0.join("art")).unwrap(); + let r = open(&mut s, &b.file("poster.vectorcraft")); + assert_eq!(r["missingLinks"][0]["name"].as_str(), Some("photo.png"), "{r}"); + save(&mut s, &b.file("poster.vectorcraft")); + std::fs::create_dir_all(b.0.join("copies")).unwrap(); + save(&mut s, &b.file("copies/poster.vectorcraft")); + let relative = |path: &str| { + let mut saved: Value = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap(); + saved_image(&mut saved)["link"]["relative"].as_str().map(str::to_string) + }; + assert_eq!(relative(&b.file("poster.vectorcraft")).as_deref(), Some("art/photo.png")); + assert_eq!(relative(&b.file("copies/poster.vectorcraft")).as_deref(), Some("../art/photo.png")); + // Saved again where the last save put it, the file is still named from the folder it was read from. + s.execute("document.save", &json!({})).unwrap(); + assert_eq!(relative(&b.file("copies/poster.vectorcraft")).as_deref(), Some("../art/photo.png")); + // The links come back: both saved documents find the file. + std::fs::rename(away.0.join("art"), b.0.join("art")).unwrap(); + let moved = b.file("art/photo.png"); + for doc in [b.file("poster.vectorcraft"), b.file("copies/poster.vectorcraft")] { + let r = open(&mut s, &doc); + assert_eq!(&r["missingLinks"], &json!([]), "{doc}: {r}"); + assert_eq!(Path::new(&image(&s, id).link.unwrap().path), Path::new(&moved), "{doc}"); + } +} + +/// A file found by name in the document's folder, then missing before the next save, keeps the +/// place it was last found: the saved relative path names that place, not the folder its old +/// relative path named. +#[test] +fn a_file_found_elsewhere_then_missing_keeps_the_place_it_was_found() { + let dir = Folder::new("found-then-missing"); + let pic = dir.file("art/photo.png"); + write(&pic, &png(600, 300, RED)); + let mut s = session(); + let id = place(&mut s, &pic); + let doc = dir.file("poster.vectorcraft"); + save(&mut s, &doc); + let beside = dir.file("photo.png"); + std::fs::rename(&pic, &beside).unwrap(); + let r = open(&mut s, &doc); + assert_eq!(&r["missingLinks"], &json!([]), "{r}"); + assert_eq!(Path::new(&image(&s, id).link.unwrap().path), Path::new(&beside)); + let away = dir.file("away.png"); + std::fs::rename(&beside, &away).unwrap(); + save(&mut s, &doc); + let mut saved: Value = serde_json::from_slice(&std::fs::read(&doc).unwrap()).unwrap(); + assert_eq!(saved_image(&mut saved)["link"]["relative"].as_str(), Some("photo.png")); +} + +/// After a save in another folder, a file Update Links finds by name beside the saved document, +/// then missing before the next save, keeps the place it was found. +#[test] +fn a_file_update_links_finds_after_a_save_elsewhere_keeps_its_place() { + let a = Folder::new("update-after-save-as-a"); + let b = Folder::new("update-after-save-as-b"); + let pic = a.file("art/photo.png"); + write(&pic, &png(600, 300, RED)); + let mut s = session(); + let id = place(&mut s, &pic); + save(&mut s, &a.file("poster.vectorcraft")); + open(&mut s, &a.file("poster.vectorcraft")); + let doc = b.file("poster.vectorcraft"); + save(&mut s, &doc); + let beside = b.file("photo.png"); + std::fs::rename(&pic, &beside).unwrap(); + let r = s.execute("links.update", &json!({"ids": [id.0]})).unwrap(); + assert_eq!(&r["missing"], &json!([]), "{r}"); + assert_eq!(Path::new(&image(&s, id).link.unwrap().path), Path::new(&beside)); + std::fs::remove_file(&beside).unwrap(); + s.execute("document.save", &json!({})).unwrap(); + let mut saved: Value = serde_json::from_slice(&std::fs::read(&doc).unwrap()).unwrap(); + assert_eq!(saved_image(&mut saved)["link"]["relative"].as_str(), Some("photo.png")); +} + +/// A file found at its relative path from the folder of the last save, away from its path and from +/// the folder the document was read from, is written relative to where it was found. +#[test] +fn a_file_found_beside_the_last_save_is_written_where_it_was_found() { + let a = Folder::new("found-beside-save-a"); + let b = Folder::new("found-beside-save-b"); + let c = Folder::new("found-beside-save-c"); + write(&a.file("art/photo.png"), &png(600, 300, RED)); + let mut s = session(); + place(&mut s, &a.file("art/photo.png")); + save(&mut s, &a.file("poster.vectorcraft")); + open(&mut s, &a.file("poster.vectorcraft")); + save(&mut s, &b.file("poster.vectorcraft")); + std::fs::rename(a.0.join("art"), b.0.join("art")).unwrap(); + save(&mut s, &c.file("poster.vectorcraft")); + let r = open(&mut s, &c.file("poster.vectorcraft")); + assert_eq!(&r["missingLinks"], &json!([]), "{r}"); +} + +/// After a moved document with a missing link is saved in another folder, `links.check` finds the +/// file once it is back at the place its relative path names from the folder the document was +/// read from, which is where the saved file looks for it too. +#[test] +fn a_missing_link_comes_back_where_the_document_saved_elsewhere_looks_for_it() { + let a = Folder::new("check-after-save-as-a"); + let b = Folder::new("check-after-save-as-b"); + let c = Folder::new("check-after-save-as-c"); + let away = Folder::new("check-after-save-as-away"); + write(&a.file("art/photo.png"), &png(600, 300, RED)); + let mut s = session(); + place(&mut s, &a.file("art/photo.png")); + save(&mut s, &a.file("poster.vectorcraft")); + std::fs::remove_dir_all(&b.0).unwrap(); + std::fs::rename(&a.0, &b.0).unwrap(); + std::fs::rename(b.0.join("art"), away.0.join("art")).unwrap(); + open(&mut s, &b.file("poster.vectorcraft")); + save(&mut s, &c.file("poster.vectorcraft")); + std::fs::rename(away.0.join("art"), b.0.join("art")).unwrap(); + let r = s.execute("links.check", &json!({})).unwrap(); + let found = r["links"][0]["found"].as_str().map(Path::new); + assert_eq!((r["missing"].as_u64(), found), (Some(0), Some(Path::new(&b.file("art/photo.png")))), "{r}"); + let r = open(&mut s, &c.file("poster.vectorcraft")); + assert_eq!(&r["missingLinks"], &json!([]), "{r}"); +} + /// Links in symbols, graph designs, patterns and opacity masks are saved with their relative /// paths, as links in the layers are: after the folder moves, each file is found and its link takes /// the new path, which the next save writes relative to the document again. @@ -781,7 +916,7 @@ fn low_res_proxy_eps_export_uses_full_resolution_from_the_linked_file() { assert_eq!((r["modifiedLinks"].clone(), r["updatedLinks"].clone()), (json!([]), json!([])), "{r}"); let key = image(&s, id).key; assert!(s.doc().unwrap().doc.images[&key].is_proxy()); - let (full, warnings) = cmd::links::full_linked_images(&s.doc().unwrap().doc, None); + let (full, warnings) = cmd::links::full_linked_images(&s.doc().unwrap().doc, Default::default()); assert!(warnings.is_empty(), "{warnings:?}"); assert!(!full.images[&key].is_proxy()); let dim = @@ -812,7 +947,7 @@ fn export_reads_a_found_svg_link_into_its_own_images_only() { let bytes = png(20, 20, RED); write(&dir.file("a.png"), &bytes); - let (full, warnings) = cmd::links::full_linked_images(&s.doc().unwrap().doc, None); + let (full, warnings) = cmd::links::full_linked_images(&s.doc().unwrap().doc, Default::default()); assert_eq!(warnings.len(), 1, "b.png is still missing: {warnings:?}"); assert_eq!(*full.images[&a.key].bytes, bytes, "a.png's pixels"); assert_eq!(full.images[&b.key].bytes, placeholder, "b.png keeps its placeholder"); @@ -845,7 +980,7 @@ fn export_reads_a_changed_svg_link_into_its_own_images_only() { let doc = &s.doc().unwrap().doc; assert!(doc.images[&a.key].is_proxy() && doc.images[&b.key].is_proxy(), "previews"); let preview = doc.images[&a.key].bytes.clone(); - let (full, warnings) = cmd::links::full_linked_images(doc, None); + let (full, warnings) = cmd::links::full_linked_images(doc, Default::default()); assert_eq!(warnings.len(), 1, "a.png is missing: {warnings:?}"); assert_eq!(*full.images[&b.key].bytes, blue, "b.png's new pixels"); assert_eq!(full.images[&a.key].bytes, preview, "a.png keeps its preview"); @@ -1002,7 +1137,7 @@ fn relative_paths_are_written_against_the_saved_file() { }; d.insert(Some(lid), 0, vectorcraft_doc::Node::new(nid, NodeKind::Image(Box::new(im)))).unwrap(); let rel = |dest: String| { - let d = cmd::links::with_relative_paths(&d, &dest).unwrap_or_else(|| d.clone()); + let d = cmd::links::with_relative_paths(&d, Default::default(), &dest).unwrap_or_else(|| d.clone()); match &d.node(nid).unwrap().kind { NodeKind::Image(im) => im.link.as_ref().unwrap().relative.clone(), _ => None, @@ -1083,15 +1218,11 @@ fn links_of_an_imported_file_are_read_where_they_are_found() { assert!((im.width, im.height) == (600, 300) && !s.doc().unwrap().doc.images[&im.key].is_proxy(), "{im:?}"); } -/// A placed file of a `.ai` file whose folder moved with the document is found by the folder the -/// file keeps relative to the document's (`src/`), and its link takes the size, modification time -/// and hash of the file found there. Not found, the link keeps no such details. -#[test] -fn links_of_an_imported_file_are_found_by_their_relative_folder() { +/// `poster.ai` in `dir`, a `.ai` file another app wrote, placing `src/photo.png` (relative to it) +/// under a path that no longer exists → its path. +fn imported_with_placed_file(dir: &Folder) -> String { use vectorcraft_testkit::ai; - let dir = Folder::new("imported-relative"); std::fs::create_dir_all(dir.0.join("src")).unwrap(); - let pic = dir.file("src/photo.png").replace('\\', "/"); let place = "%AI5_BeginPlace\n%%FileType: 1\n[0.1 0 0 -0.1 20 80] 0 0 600 300 4 4 0\n(/no/such/folder/src/photo.png)`\n\ %%IncludeFile:/no/such/folder/src/photo.png\n%AI17_Begin_Content_if_version_gt:24 12\n%AI28_BeginPlacedRelativePath\n(src/)\n\ %AI28_EndPlacedRelativePath\n%AI17_Alternate_Content\n%AI17_End_Versioned_Content\n%AI26_BeginPlacedObjectPreview\n\ @@ -1099,6 +1230,17 @@ fn links_of_an_imported_file_are_found_by_their_relative_folder() { let doc_path = dir.file("poster.ai"); let data = ai::editing_data(200.0, 100.0, &ai::layer("Art", place)); write(&doc_path, &ai::ai(data.as_bytes(), "0.56 0.56 0.87 rg 20 50 60 30 re f")); + doc_path +} + +/// A placed file of a `.ai` file whose folder moved with the document is found by the folder the +/// file keeps relative to the document's (`src/`), and its link takes the size, modification time +/// and hash of the file found there. Not found, the link keeps no such details. +#[test] +fn links_of_an_imported_file_are_found_by_their_relative_folder() { + let dir = Folder::new("imported-relative"); + let pic = dir.file("src/photo.png").replace('\\', "/"); + let doc_path = imported_with_placed_file(&dir); let link = |s: &Session| { let mut found = vec![]; s.doc().unwrap().doc.visit_images(|_, im| found.push(im.clone())); @@ -1112,9 +1254,16 @@ fn links_of_an_imported_file_are_found_by_their_relative_folder() { let (l, proxy) = link(&s); assert_eq!((l.path.as_str(), l.relative.as_deref()), ("/no/such/folder/src/photo.png", Some("src/photo.png"))); assert!(proxy && l.size.is_none() && l.modified.is_none() && l.hash.is_none(), "{l:?}"); + // Saved beside it while the file is missing, the document keeps the folder the file is in. + let native = dir.file("poster.vectorcraft"); + save(&mut s, &native); + let mut saved: Value = serde_json::from_slice(&std::fs::read(&native).unwrap()).unwrap(); + assert_eq!(saved_image(&mut saved)["link"]["relative"].as_str(), Some("src/photo.png")); let bytes = png(600, 300, RED); write(&pic, &bytes); + let r = open(&mut s, &native); + assert_eq!(&r["missingLinks"], &json!([]), "the native copy finds the file: {r}"); let r = open(&mut s, &doc_path); assert_eq!(&r["missingLinks"], &json!([]), "{r}"); let (l, proxy) = link(&s); @@ -1123,3 +1272,27 @@ fn links_of_an_imported_file_are_found_by_their_relative_folder() { assert_eq!((l.size, l.hash.clone()), (Some(bytes.len() as u64), Some(vectorcraft_doc::links::hash_bytes(&bytes)))); assert!(l.modified.is_some(), "{l:?}"); } + +/// A Data Recovery copy of such a document keeps the file its links' relative paths name places +/// from: restored and saved beside the `.ai` file, it keeps the folder the placed file is in. +#[test] +fn a_recovered_copy_of_an_imported_file_keeps_its_links_folder() { + let dir = Folder::new("imported-recovered"); + let doc_path = imported_with_placed_file(&dir); + let store = std::sync::Arc::new(crate::cmd::recovery::MemoryStore::default()); + let mut s = session(); + s.recovery.set_store(store.clone()); + open(&mut s, &doc_path); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap(); + let r = s.execute("file.recovery.save", &json!({})).unwrap(); + assert_eq!(r["saved"].as_array().map(Vec::len), Some(1), "{r}"); + drop(s); + let mut s = Session::new(); + s.recovery.set_store(store); + let r = s.execute("file.recovery.restore", &json!({})).unwrap(); + assert_eq!(r["restored"].as_array().map(Vec::len), Some(1), "{r}"); + let native = dir.file("poster.vectorcraft"); + save(&mut s, &native); + let mut saved: Value = serde_json::from_slice(&std::fs::read(&native).unwrap()).unwrap(); + assert_eq!(saved_image(&mut saved)["link"]["relative"].as_str(), Some("src/photo.png")); +} diff --git a/crates/engine/src/tests/menucmds.rs b/crates/engine/src/tests/menucmds.rs index 4e6100df9..1bc75a82a 100644 --- a/crates/engine/src/tests/menucmds.rs +++ b/crates/engine/src/tests/menucmds.rs @@ -2104,7 +2104,7 @@ fn type_menu_and_find_commands_refuse_a_param_of_the_wrong_type() { let switch = |k: &str| format!("`{k}` must be true or false"); let scope = "`scope` must be one of selection, document".to_string(); let not_a_list = "ids must be an array of non-negative integer object ids".to_string(); - let not_an_id = "invalid object id \"x\": expected a non-negative integer".to_string(); + let not_an_id = |entry: usize| format!("`ids` entry {entry} is \"x\", not a non-negative integer object id"); for (cmd, p, want) in [ ("edit.findReplace", json!({"find": "cat", "replace": 5}), string("replace")), ("edit.findReplace", json!({"find": 5, "replace": "dog"}), string("find")), @@ -2112,7 +2112,8 @@ fn type_menu_and_find_commands_refuse_a_param_of_the_wrong_type() { ("edit.findReplace", json!({"find": "cat", "replace": "dog", "wholeWord": "yes"}), switch("wholeWord")), ("edit.findReplace", json!({"find": "cat", "replace": "dog", "ids": t.0}), not_a_list), ("edit.findReplace", json!({"find": "cat", "replace": "dog", "ids": [t.0, 999_999]}), "no such object id 999999".to_string()), - ("edit.findReplace", json!({"find": "cat", "replace": "dog", "ids": ["x"]}), not_an_id), + ("edit.findReplace", json!({"find": "cat", "replace": "dog", "ids": ["x"]}), not_an_id(0)), + ("edit.findReplace", json!({"find": "cat", "replace": "dog", "ids": [t.0, "x"]}), not_an_id(1)), ("edit.findNext", json!({"find": ["cat"]}), string("find")), ("edit.findNext", json!({"find": "cat", "matchCase": 1}), switch("matchCase")), ("edit.findNext", json!({"find": "cat", "wholeWord": "false"}), switch("wholeWord")), @@ -4462,7 +4463,7 @@ fn k_only(c: Color) -> f32 { fn document_color_mode_separates_rgb_greys_through_the_profile() { let (mut s, ids, _, _) = greys_document(); s.execute("file.documentColorMode", &json!({"mode": "cmyk"})).unwrap(); - // As Illustrator converts them: four-colour greys and a rich black. + // Through the profile: a mid gray in four inks and a rich black. let Color::Cmyk { c, m, y, k } = fill_of(&s, ids[2]) else { panic!("not CMYK") }; assert!(c > 0.05 && m > 0.05 && y > 0.05 && k > 0.0, "a four-colour mid grey: {c} {m} {y} {k}"); let Color::Cmyk { c, k, .. } = fill_of(&s, ids[0]) else { panic!("not CMYK") }; diff --git a/crates/engine/src/tests/objexpand.rs b/crates/engine/src/tests/objexpand.rs index 4210a1d96..6c5c05572 100644 --- a/crates/engine/src/tests/objexpand.rs +++ b/crates/engine/src/tests/objexpand.rs @@ -925,11 +925,12 @@ fn a_freeform_gradient_expands_with_the_inks_of_its_points() { for c in expanded(&mut s, 1.0) { assert!(close_inks(inks(c), [0.5, 0.2, 0.0, 0.1]), "{c:?}"); } - // Cyan and black points mix as inks: cyan and black only, adding up to one. + // Cyan and black points mix as inks: cyan and black only, adding up to one. The gradient blends + // the points' display colors and the mesh blends their inks, so the two draw a little apart in between. let mut s = session(); let (cyan, black) = (ink(1.0, 0.0, 0.0, 0.0), ink(0.0, 0.0, 0.0, 1.0)); freeform_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 180.0), [cyan.clone(), cyan.clone(), black.clone(), black]); - for c in expanded(&mut s, 5.0) { + for c in expanded(&mut s, 7.5) { let [c, m, y, k] = inks(c); assert!(m.abs() < 1e-5 && y.abs() < 1e-5 && (c + k - 1.0).abs() < 1e-5, "C{c} M{m} Y{y} K{k}"); } diff --git a/crates/engine/src/tests/package.rs b/crates/engine/src/tests/package.rs index dcf3c6217..e15258bec 100644 --- a/crates/engine/src/tests/package.rs +++ b/crates/engine/src/tests/package.rs @@ -253,6 +253,103 @@ fn a_missing_link_is_reported_not_copied() { assert!(report.contains("blue.png: not found"), "{report}"); } +/// A file missing from a document that moved keeps the place its relative path names from the +/// document's folder: the packaged document writes its relative path from there. +#[test] +fn a_missing_link_of_a_moved_document_keeps_its_relative_place() { + let a = Folder::new("package-moved-a"); + let b = Folder::new("package-moved-b"); + let mut s = poster(&a); + std::fs::remove_dir_all(&b.0).unwrap(); + std::fs::rename(&a.0, &b.0).unwrap(); + std::fs::remove_file(b.file("art/blue.png")).unwrap(); + open(&mut s, &b.file("poster.vectorcraft")); + // Saved in another folder first: the relative path still names the folder the file was read from. + std::fs::create_dir_all(b.0.join("saved")).unwrap(); + save(&mut s, &b.file("saved/poster.vectorcraft")); + let out = b.file("out"); + let r = run(&mut s, "file.package", json!({"folder": out})); + assert_eq!(r["missingLinks"], json!(["blue.png"]), "{r}"); + let packaged = std::path::Path::new(&out).join("poster Folder/poster.vectorcraft"); + let doc = vectorcraft_format::load(&std::fs::read(packaged).unwrap()).unwrap(); + let mut rel = vec![]; + doc.visit_images(|_, im| rel.push(im.link.clone().unwrap().relative)); + rel.sort(); + assert_eq!(rel, [Some("../../art/blue.png".to_string()), Some("Links/red.png".to_string())]); +} + +/// A missing link stays missing when another file sits where its relative path, written for the +/// package folder, would name from the folder the document was last saved in. +#[test] +fn a_missing_link_is_not_collected_from_another_place() { + let a = Folder::new("package-other-a"); + let b = Folder::new("package-other-b"); + let mut s = poster(&a); + std::fs::remove_dir_all(&b.0).unwrap(); + std::fs::rename(&a.0, &b.0).unwrap(); + std::fs::remove_file(b.file("art/blue.png")).unwrap(); + open(&mut s, &b.file("poster.vectorcraft")); + std::fs::create_dir_all(b.0.join("s1/s2/s3")).unwrap(); + save(&mut s, &b.file("s1/s2/s3/poster.vectorcraft")); + // `../../art/blue.png` from the package folder, read from `s1/s2/s3`. + write(&b.file("s1/art/blue.png"), &png(10, 10, RED)); + let r = run(&mut s, "file.package", json!({"folder": b.file("out")})); + assert_eq!((r["links"].as_u64(), r["missingLinks"].clone()), (Some(1), json!(["blue.png"])), "{r}"); +} + +/// Without copying, a packaged document links a file found by name beside the document it was read +/// from, after a save in another folder, where it was found. +#[test] +fn a_link_found_by_name_is_kept_where_it_was_found_without_copying() { + let a = Folder::new("package-name-a"); + let b = Folder::new("package-name-b"); + let mut s = poster(&a); + std::fs::remove_dir_all(&b.0).unwrap(); + std::fs::rename(&a.0, &b.0).unwrap(); + let away = b.file("away.png"); + std::fs::rename(b.file("art/blue.png"), &away).unwrap(); + open(&mut s, &b.file("poster.vectorcraft")); + std::fs::create_dir_all(b.0.join("saved")).unwrap(); + save(&mut s, &b.file("saved/poster.vectorcraft")); + std::fs::rename(&away, b.file("blue.png")).unwrap(); + let out = b.file("out"); + run(&mut s, "file.package", json!({"folder": out, "copyLinks": false})); + let packaged = std::path::Path::new(&out).join("poster Folder/poster.vectorcraft"); + let r = open(&mut s, &packaged.to_string_lossy()); + assert_eq!(&r["missingLinks"], &json!([]), "{r}"); +} + +/// A file that comes back beside the document it was read from, after the document was saved in +/// another folder, is collected from there. +#[test] +fn a_link_that_comes_back_after_a_save_elsewhere_is_collected() { + let a = Folder::new("package-back-a"); + let b = Folder::new("package-back-b"); + let away = Folder::new("package-back-away"); + let mut s = poster(&a); + std::fs::remove_dir_all(&b.0).unwrap(); + std::fs::rename(&a.0, &b.0).unwrap(); + std::fs::rename(b.file("art/blue.png"), away.file("blue.png")).unwrap(); + open(&mut s, &b.file("poster.vectorcraft")); + std::fs::create_dir_all(b.0.join("saved")).unwrap(); + save(&mut s, &b.file("saved/poster.vectorcraft")); + std::fs::rename(away.file("blue.png"), b.file("art/blue.png")).unwrap(); + let out = b.file("out"); + let r = run(&mut s, "file.package", json!({"folder": out})); + assert_eq!((r["links"].as_u64(), r["missingLinks"].clone()), (Some(2), json!([])), "{r}"); + let packaged = std::path::Path::new(&out).join("poster Folder/poster.vectorcraft"); + let doc = vectorcraft_format::load(&std::fs::read(packaged).unwrap()).unwrap(); + let mut rel = vec![]; + doc.visit_images(|_, im| rel.push(im.link.clone().unwrap().relative)); + rel.sort(); + assert_eq!(rel, [Some("Links/blue.png".to_string()), Some("Links/red.png".to_string())]); + // A file beside the folder the document was last saved in is collected too. + std::fs::create_dir_all(b.0.join("saved/art")).unwrap(); + std::fs::rename(b.file("art/blue.png"), b.file("saved/art/blue.png")).unwrap(); + let r = run(&mut s, "file.package", json!({"folder": b.file("out2")})); + assert_eq!((r["links"].as_u64(), r["missingLinks"].clone()), (Some(2), json!([])), "{r}"); +} + #[test] fn a_font_named_by_another_of_its_names_is_copied() { let dir = Folder::new("package-alias"); diff --git a/crates/engine/src/tests/paragraphs.rs b/crates/engine/src/tests/paragraphs.rs index 93cd13e57..5355cee5b 100644 --- a/crates/engine/src/tests/paragraphs.rs +++ b/crates/engine/src/tests/paragraphs.rs @@ -151,6 +151,42 @@ fn return_in_the_type_tool_continues_the_paragraph_it_splits() { assert_eq!(justs(&s, id), [Justify::Right, Justify::Center]); } +#[test] +fn tab_at_the_start_of_a_list_item_moves_it_a_level_down_in_one_undo_step() { + let mut s = session(); + let v = ViewInfo::default(); + let id = area(&mut s, "one\ntwo"); + s.execute("text.setFormat", &json!({"ids": [id], "list": "number"})).unwrap(); + s.select_tool("type", v).unwrap(); + // Click into the second line, then Home: the caret before "two". + let t = text(&s, id); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + let l1 = &lay.lines[1]; + let at = t.xf * vectorcraft_geom::Point::new((l1.x0 + l1.x1) / 2.0, l1.baseline - 3.0); + for k in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(k, at.x, at.y), v).unwrap(); + } + s.tool_key(ToolKey::Home, Mods::default(), v).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + s.tool_key(ToolKey::Tab, Mods::default(), v).unwrap(); + let levels = |s: &Session| text(s, id).paragraph_styles().iter().map(|p| p.list.map(|l| l.level)).collect::>(); + assert_eq!(levels(&s), [Some(1), Some(2)]); + assert_eq!((text(&s, id).plain_text(), s.doc().unwrap().history.undo.len()), ("one\ntwo".to_string(), undo + 1)); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(&s, id)); + let starts: Vec = lay.markers.iter().map(|m| lay.glyphs[m.glyphs.start].origin.x).collect(); + assert!((starts[1] - starts[0] - 18.0).abs() < 1e-6, "the item moved 18 pt in: {starts:?}"); + assert_eq!(lay.markers.iter().map(|m| m.text.as_str()).collect::>(), ["1.", "1."]); + // Shift+Tab moves it back up, and typing then goes on at the caret. + s.tool_key(ToolKey::Tab, Mods { shift: true, ..Mods::default() }, v).unwrap(); + assert_eq!(levels(&s), [Some(1), Some(1)]); + s.tool_text("and ", v).unwrap(); + s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap(); + assert_eq!(text(&s, id).plain_text(), "one\nand two"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((text(&s, id).plain_text(), levels(&s)), ("one\ntwo".to_string(), vec![Some(1), Some(2)])); +} + #[test] fn paragraph_style_applies_to_one_paragraph() { let mut s = session(); diff --git a/crates/engine/src/tests/placed_document.rs b/crates/engine/src/tests/placed_document.rs index 5f989db78..5871b85a3 100644 --- a/crates/engine/src/tests/placed_document.rs +++ b/crates/engine/src/tests/placed_document.rs @@ -489,7 +489,7 @@ fn a_placed_document_without_its_bytes_or_a_hash_is_reported_not_read() { let Some(NodeKind::PlacedDocument(p)) = d.node_mut(id).map(|n| &mut n.kind) else { panic!("a placed document") }; p.link.hash = None; p.link.size = None; - let r = crate::cmd::links::resolve(&mut d, None, false, false).to_json(); + let r = crate::cmd::links::resolve(&mut d, crate::cmd::links::LinkBase::file(None), false, false).to_json(); assert_eq!(r["modifiedLinks"].as_array().map(Vec::len), Some(1), "{r}"); assert!(!d.images.contains_key(&key), "not read"); } diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index d11f13bee..79bae46c8 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -17,8 +17,9 @@ fn run(s: &mut Session, cmd: &str, p: Value) -> Value { s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) } +/// Installs `wasm`, in place of any module installed with its id (`replace: true`). fn install(s: &mut Session, wasm: &[u8]) -> Value { - run(s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(wasm)})) + run(s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(wasm), "replace": true})) } fn install_wat(s: &mut Session, src: &str) -> Value { @@ -74,6 +75,52 @@ fn object_filter_recolours_the_selection_as_one_undo_step() { assert!(matches!(s.execute("plugin.run", &json!({"id": DESATURATE_ID, "amount": "all"})), Err(EngineError::BadParams { .. }))); } +/// `plugin.run` refuses an `ids` list with an entry that is not an object id, with an error naming +/// the key, the entry and its value, and changes nothing. With `ids` left out or null the filter +/// runs on the selection, as it does on the list of the selection. +#[test] +fn object_filter_ids_entries_must_be_ids_and_ids_left_out_take_the_selection() { + // A session with the plug-in, a red and a blue rectangle, and the red one selected. The + // document has no creation date, so documents made in different seconds compare equal. + let fresh = || { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 400, "height": 200, "created": null})); + install(&mut s, DESATURATE); + let a = rect(&mut s, 10.0, "#ff0000"); + let b = rect(&mut s, 40.0, "#0000ff"); + run(&mut s, "select.set", json!({"ids": [a.0]})); + (s, a, b) + }; + let (_, a, b) = fresh(); + for (bad, entry) in [(json!(["abc"]), r#"entry 0 is "abc""#), (json!([a.0, true]), "entry 1 is true"), (json!([{}]), "entry 0 is {}")] { + let (mut s, ..) = fresh(); + let st = s.doc().unwrap(); + let before = (st.doc.clone(), st.selection.clone(), st.history.undo.len()); + let r = s.execute("plugin.run", &json!({"id": DESATURATE_ID, "amount": 100, "ids": bad})); + let want = format!("`ids` {entry}, not a non-negative integer object id"); + assert!(matches!(&r, Err(EngineError::BadParams { cmd, msg }) if cmd == "plugin.run" && *msg == want), "{bad}: {r:?}"); + let st = s.doc().unwrap(); + assert!((st.doc.clone(), st.selection.clone(), st.history.undo.len()) == before, "{bad}: something changed"); + } + let gray = |s: &Session, id: NodeId| { + let [r, g, b] = node(s, id).appearance.fill_paint().color().unwrap().to_rgb_uncalibrated(); + (r - g).abs() < 1e-6 && (g - b).abs() < 1e-6 + }; + let mut docs = vec![]; + for ids in [None, Some(Value::Null), Some(json!([a.0]))] { + let (mut s, ..) = fresh(); + let mut p = json!({"id": DESATURATE_ID, "amount": 100}); + if let Some(ids) = &ids { + p["ids"] = ids.clone(); + } + let r = run(&mut s, "plugin.run", p); + assert_eq!(r["ids"], json!([a.0]), "{ids:?}"); + assert!(gray(&s, a) && !gray(&s, b), "{ids:?}: only the selected rectangle turns gray"); + docs.push(s.doc().unwrap().doc.clone()); + } + assert!(docs.windows(2).all(|w| w[0] == w[1]), "left out, null and the selection's list give the same document"); +} + #[test] fn object_filters_add_replace_and_generate_objects() { let mut s = session(); @@ -532,9 +579,9 @@ fn asking(id: &str, permissions: &str, output: &Value) -> Vec { /// A plug-in whose manifest lists permissions installs only once they are all granted (`grant`), /// which `plugin.inspect` tells beforehand (`needsApproval`) with the permissions and the module's -/// hash. The grant is recorded with that hash and covers only that module: one with other bytes -/// needs a new grant. Removing it forgets the grant. A plug-in without `permissions` installs as -/// before, with the ones its kind implies. +/// hash. The grant is recorded with that hash and covers only that module: replacing the plug-in +/// (`replace: true`) with one with other bytes needs a new grant. Removing it forgets the grant. A +/// plug-in without `permissions` installs as before, with the ones its kind implies. #[test] fn a_plugin_that_lists_permissions_installs_once_they_are_granted() { let mut s = session(); @@ -573,15 +620,16 @@ fn a_plugin_that_lists_permissions_installs_once_they_are_granted() { // Replaced with another module that lists the same permissions: granted again, the grant now // records the new module's hash. let other = asking(id, r#"["doc.read"]"#, &json!({"objects": [], "error": null})); - run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&other), "grant": ["doc.read"]})); + assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": vectorcraft_format::base64_encode(&other)}))["needsApproval"], true); + run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&other), "grant": ["doc.read"], "replace": true})); assert_eq!(vectorcraft_plugins::registry::granted(id).unwrap().hash, vectorcraft_plugins::permissions::sha256_hex(&other)); // One that asks for more is refused until it is granted all of them. let more = data(r#"["doc.read","doc.write"]"#); assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": more}))["needsApproval"], true); - let e = s.execute("plugin.install", &json!({"dataBase64": more})).unwrap_err().to_string(); + let e = s.execute("plugin.install", &json!({"dataBase64": more, "replace": true})).unwrap_err().to_string(); assert!(e.contains("doc.read, doc.write") && e.contains("`grant`"), "{e}"); assert_eq!(vectorcraft_plugins::registry::get(id).unwrap().manifest().permissions().len(), 1, "the one installed stays"); - run(&mut s, "plugin.install", json!({"dataBase64": more, "grant": ["doc.write", "doc.read", "state"]})); + run(&mut s, "plugin.install", json!({"dataBase64": more, "grant": ["doc.write", "doc.read", "state"], "replace": true})); assert_eq!(vectorcraft_plugins::registry::granted(id).unwrap().permissions.len(), 2, "only what it lists is recorded"); // Removed, it is asked again. run(&mut s, "plugin.remove", json!({"id": id})); @@ -608,9 +656,9 @@ fn a_grant_covers_only_the_module_that_was_approved() { let (first_hash, changed_hash) = (sha256_hex(&first), sha256_hex(&changed)); run(&mut s, "plugin.install", json!({"dataBase64": first_data, "grant": ["doc.read"]})); assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": changed_data}))["needsApproval"], true); - let e = s.execute("plugin.install", &json!({"dataBase64": changed_data})).unwrap_err().to_string(); + let e = s.execute("plugin.install", &json!({"dataBase64": changed_data, "replace": true})).unwrap_err().to_string(); assert!(e.contains("doc.read") && e.contains("`grant`"), "{e}"); - let e = s.execute("plugin.install", &json!({"dataBase64": changed_data, "hash": changed_hash})).unwrap_err().to_string(); + let e = s.execute("plugin.install", &json!({"dataBase64": changed_data, "replace": true, "hash": changed_hash})).unwrap_err().to_string(); assert!(e.contains("`grant`"), "`hash` is not an approval: {e}"); assert_eq!(vectorcraft_plugins::registry::get(id).unwrap().hash(), first_hash, "the installed module stays"); assert_eq!(vectorcraft_plugins::registry::granted(id).unwrap().hash, first_hash, "its grant stays"); @@ -618,7 +666,7 @@ fn a_grant_covers_only_the_module_that_was_approved() { assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": first_data}))["needsApproval"], false); run(&mut s, "plugin.install", json!({"dataBase64": first_data})); // A new grant installs the changed module, and the old bytes are no longer covered. - run(&mut s, "plugin.install", json!({"dataBase64": changed_data, "grant": ["doc.read"], "hash": changed_hash})); + run(&mut s, "plugin.install", json!({"dataBase64": changed_data, "grant": ["doc.read"], "hash": changed_hash, "replace": true})); assert_eq!(vectorcraft_plugins::registry::get(id).unwrap().hash(), changed_hash); assert_eq!(vectorcraft_plugins::registry::granted(id).unwrap().hash, changed_hash); assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": first_data}))["needsApproval"], true); @@ -629,7 +677,7 @@ fn a_grant_covers_only_the_module_that_was_approved() { for (permissions, out) in [("null", json!({"objects": []})), ("null", json!({"objects": [], "error": null})), ("[]", json!({"objects": []}))] { let data = vectorcraft_format::base64_encode(&asking(exempt, permissions, &out)); assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": data}))["needsApproval"], false, "{permissions}"); - run(&mut s, "plugin.install", json!({"dataBase64": data})); + run(&mut s, "plugin.install", json!({"dataBase64": data, "replace": true})); } run(&mut s, "plugin.remove", json!({"id": exempt})); } @@ -648,28 +696,30 @@ fn a_module_without_permissions_ends_the_grant_of_its_id() { let granted = || vectorcraft_plugins::registry::granted(id).map(|g| g.hash); for permissions in ["null", "[]"] { let plain = vectorcraft_format::base64_encode(&asking(id, permissions, &empty)); - run(&mut s, "plugin.install", json!({"dataBase64": approved_data, "grant": ["doc.read"]})); + run(&mut s, "plugin.install", json!({"dataBase64": approved_data, "grant": ["doc.read"], "replace": true})); // Refused installs change nothing. - let e = s.execute("plugin.install", &json!({"dataBase64": plain, "hash": "0".repeat(64)})).unwrap_err().to_string(); + let e = s.execute("plugin.install", &json!({"dataBase64": plain, "hash": "0".repeat(64), "replace": true})).unwrap_err().to_string(); assert!(e.contains("SHA-256 `hash`"), "{e}"); let e = s.execute("plugin.install", &json!({"dataBase64": plain, "replace": false})).unwrap_err().to_string(); assert!(e.contains("already installed"), "{e}"); + let e = s.execute("plugin.install", &json!({"dataBase64": plain})).unwrap_err().to_string(); + assert!(e.contains("`replace: true`"), "{e}"); assert_eq!(granted(), Some(sha256_hex(&approved)), "{permissions}: a refused install leaves the grant"); - run(&mut s, "plugin.install", json!({"dataBase64": plain})); + run(&mut s, "plugin.install", json!({"dataBase64": plain, "replace": true})); assert_eq!(granted(), None, "{permissions}"); assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": approved_data}))["needsApproval"], true, "{permissions}"); - let e = s.execute("plugin.install", &json!({"dataBase64": approved_data})).unwrap_err().to_string(); + let e = s.execute("plugin.install", &json!({"dataBase64": approved_data, "replace": true})).unwrap_err().to_string(); assert!(e.contains("`grant`"), "{permissions}: {e}"); assert!(!vectorcraft_plugins::registry::get(id).unwrap().manifest().asks_permissions(), "{permissions}: the module installed stays"); } // With no module installed under the id, the grant ends the same way. - run(&mut s, "plugin.install", json!({"dataBase64": approved_data, "grant": ["doc.read"]})); + run(&mut s, "plugin.install", json!({"dataBase64": approved_data, "grant": ["doc.read"], "replace": true})); vectorcraft_plugins::registry::remove(id); run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&asking(id, "null", &empty))})); assert_eq!(granted(), None); // A module that lists permissions records its own grant. - run(&mut s, "plugin.install", json!({"dataBase64": approved_data, "grant": ["doc.read"]})); + run(&mut s, "plugin.install", json!({"dataBase64": approved_data, "grant": ["doc.read"], "replace": true})); assert_eq!(granted(), Some(sha256_hex(&approved))); run(&mut s, "plugin.remove", json!({"id": id})); } @@ -701,7 +751,7 @@ fn a_module_without_permissions_ends_the_grant_kept_in_the_store() { run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&other_wasm), "grant": ["doc.read"]})); run(&mut s, "plugin.install", json!({"dataBase64": approved_data, "grant": ["doc.read"]})); assert_eq!(ids().len(), 2); - run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&plain)})); + run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&plain), "replace": true})); assert_eq!(ids(), vec![other.to_string()], "only this id's entry goes"); assert!(vectorcraft_plugins::registry::granted(id).is_none()); assert!(vectorcraft_plugins::registry::granted(other).is_some(), "the other plug-in's grant stays"); @@ -738,8 +788,8 @@ fn a_module_without_permissions_ends_the_grant_kept_in_the_store() { /// A module listing no permissions that can't be written to the plug-in store leaves the stored /// module in place, so `grants.json` keeps the grant that module needs at the next launch. #[test] -fn a_failed_store_write_keeps_the_stored_grant() { - let folder = crate::tests_links::Folder::new("plugin-store-failed-write"); +fn a_failed_store_write_of_a_module_without_permissions_keeps_the_stored_grant() { + let folder = crate::tests_links::Folder::new("plugin-store-failed-write-plain"); let id = "test.engine.permissions.storefail"; let empty = json!({"objects": []}); let approved = asking(id, r#"["doc.read"]"#, &empty); @@ -753,7 +803,7 @@ fn a_failed_store_write_keeps_the_stored_grant() { std::fs::remove_file(&file).unwrap(); std::fs::create_dir(&file).unwrap(); - run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&plain)})); + run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&plain), "replace": true})); assert_eq!(std::fs::read(&grants).unwrap(), before, "the grant stays with the module left in the store"); assert!(vectorcraft_plugins::registry::granted(id).is_none(), "the process runs the module that lists nothing"); @@ -807,7 +857,7 @@ fn a_plugin_folder_ends_the_process_grant_of_an_id_it_replaces() { #[test] fn a_stored_grant_covers_only_the_stored_module_it_recorded() { use vectorcraft_plugins::permissions::{Grant, Permission, sha256_hex}; - let folder = crate::tests_links::Folder::new("plugin-store-grant-hash"); + 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": []})); let changed = asking(id, r#"["doc.read"]"#, &json!({"objects": [], "error": null})); @@ -857,6 +907,87 @@ fn a_stored_grant_covers_only_the_stored_module_it_recorded() { run(&mut attached, "plugin.remove", json!({"id": id})); } +/// A write to the plug-in store that fails leaves the store's module beside the grant that approved +/// it, so the old module loads at the next start: a module that can't be written doesn't update the +/// grant, and a grant that can't be written puts the old module back. On a first install, it takes +/// the new module out again. +#[test] +fn a_failed_store_write_keeps_the_stored_module_with_its_grant() { + use vectorcraft_plugins::permissions::sha256_hex; + let folder = crate::tests_links::Folder::new("plugin-store-failed-write"); + let id = "test.engine.permissions.failedwrite"; + let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []})); + let changed = asking(id, r#"["doc.read","doc.write"]"#, &json!({"objects": []})); + let first_data = vectorcraft_format::base64_encode(&first); + let update = json!({"dataBase64": vectorcraft_format::base64_encode(&changed), "grant": ["doc.read", "doc.write"], "replace": true}); + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + run(&mut s, "plugin.install", json!({"dataBase64": first_data, "grant": ["doc.read"]})); + let grants = folder.0.join("grants.json"); + let recorded = std::fs::read(&grants).unwrap(); + let module = std::path::PathBuf::from(s.stored_plugin_file(id).unwrap()); + let starts = || { + vectorcraft_plugins::registry::remove(id); + vectorcraft_plugins::registry::revoke(id); + let mut next = session(); + next.attach_plugin_store(folder.0.clone()); + vectorcraft_plugins::registry::get(id).map(|p| p.hash().to_string()) + }; + let block = |path: &std::path::Path| { + std::fs::remove_file(path).unwrap(); + std::fs::create_dir_all(path.join("block")).unwrap(); + }; + + // The module can't be written: the grant stays the old module's. + block(&module); + run(&mut s, "plugin.install", update.clone()); + assert_eq!(std::fs::read(&grants).unwrap(), recorded, "the grant isn't updated for a module that wasn't stored"); + std::fs::remove_dir_all(&module).unwrap(); + std::fs::write(&module, &first).unwrap(); + assert_eq!(starts(), Some(sha256_hex(&first)), "the old module loads after a failed module write"); + + // The grants file is full: the new grant, which is longer, doesn't fit. + let cap = 1 << 20; + let mut entries: Vec = serde_json::from_slice(&recorded).unwrap(); + entries.push(json!({"id": "", "pad": ""})); + let room = cap - serde_json::to_vec(&entries).unwrap().len(); + *entries.last_mut().unwrap() = json!({"id": "", "pad": "x".repeat(room)}); + let full = serde_json::to_vec(&entries).unwrap(); + assert_eq!(full.len(), cap); + std::fs::write(&grants, &full).unwrap(); + run(&mut s, "plugin.install", update.clone()); + assert_eq!(std::fs::read(&grants).unwrap(), full, "not written past the cap"); + assert_eq!(std::fs::read(&module).unwrap(), first, "the old module is put back"); + assert_eq!(starts(), Some(sha256_hex(&first)), "the old module loads after a failed grant write"); + + // The grants file can't be read, so nothing can be recorded in it. + block(&grants); + run(&mut s, "plugin.install", update.clone()); + assert_eq!(std::fs::read(&module).unwrap(), first, "the old module is put back"); + std::fs::remove_dir_all(&grants).unwrap(); + std::fs::write(&grants, &recorded).unwrap(); + assert_eq!(starts(), Some(sha256_hex(&first)), "the old module loads once the grants are readable"); + + // A stored module that can't be read back isn't replaced, so a failed grant write can't lose it. + let oversized = vectorcraft_plugins::Limits::default().max_module_bytes as u64 + 1; + std::fs::File::options().write(true).open(&module).unwrap().set_len(oversized).unwrap(); + block(&grants); + run(&mut s, "plugin.install", update); + assert_eq!(std::fs::metadata(&module).unwrap().len(), oversized, "the stored module stays"); + std::fs::remove_dir_all(&grants).unwrap(); + std::fs::write(&grants, &recorded).unwrap(); + std::fs::write(&module, &first).unwrap(); + + // A first install whose grant can't be written leaves no module without a grant in the store. + run(&mut s, "plugin.remove", json!({"id": id})); + assert!(!module.exists() && !grants.exists()); + std::fs::create_dir_all(grants.join("block")).unwrap(); + run(&mut s, "plugin.install", json!({"dataBase64": first_data, "grant": ["doc.read"]})); + assert!(!module.exists(), "the store holds no module its grants don't cover"); + vectorcraft_plugins::registry::remove(id); + vectorcraft_plugins::registry::revoke(id); +} + /// A plug-in folder reports a changed module as needing approval and still loads the other files; /// granted on its own, the changed module loads from the folder. A session without a store uses the /// grants held by the process. @@ -867,7 +998,7 @@ fn a_plugin_folder_skips_a_changed_module_until_it_is_approved() { let plain = "test.engine.permissions.folderplain"; let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []})); let changed = asking(id, r#"["doc.read"]"#, &json!({"objects": [], "error": null})); - let dir = crate::tests_links::Folder::new("plugin-folder-grant-hash"); + let dir = crate::tests_links::Folder::new("plugin-folder-changed-module"); let file = dir.0.join("asks.wasm"); std::fs::write(&file, &first).unwrap(); std::fs::write(dir.0.join("plain.wasm"), asking(plain, "null", &json!({"objects": []}))).unwrap(); @@ -887,7 +1018,7 @@ fn a_plugin_folder_skips_a_changed_module_until_it_is_approved() { let hash = vectorcraft_plugins::permissions::sha256_hex(&first); assert_eq!(vectorcraft_plugins::registry::get(id).unwrap().hash(), hash, "the module installed earlier stays"); - run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&changed), "grant": ["doc.read"]})); + run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&changed), "grant": ["doc.read"], "replace": true})); let r = run(&mut s, "plugin.reload", path); assert_eq!(r["failed"], json!([]), "approved on its own, it loads: {r}"); run(&mut s, "plugin.remove", json!({"id": id})); @@ -903,7 +1034,7 @@ fn a_filter_does_only_what_its_permissions_allow() { let id = rect(&mut s, 10.0, "#ff0000"); let filter = |s: &mut Session, pid: &str| s.execute("plugin.run", &json!({"id": pid, "ids": [id.0]})); let grant = |s: &mut Session, wasm: &[u8], perms: Value| { - run(s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(wasm), "grant": perms})); + run(s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(wasm), "grant": perms, "replace": true})); }; // Denied `doc.write`: renaming the object is refused, and the document and history stay. let reader = "test.engine.permissions.reader"; @@ -1047,6 +1178,66 @@ fn granted_permissions_are_kept_and_folders_skip_the_rest() { run(&mut granted, "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 +/// doesn't get the settings kept for the id until the user grants it. A kept grant without a hash +/// covers no module. +#[test] +fn a_grant_covers_only_the_module_it_was_given_to() { + let folder = crate::tests_links::Folder::new("plugin-store-grant-hash"); + let id = "test.engine.granthash"; + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + install_storing(&mut s, id, WRITES); + run(&mut s, "plugin.run", json!({"id": id, "ids": []})); + // The stored module is replaced on disk by one that reads the settings. + let other = storing(id, READS); + let stored: Vec<_> = + std::fs::read_dir(&folder.0).unwrap().map(|e| e.unwrap().path()).filter(|p| p.extension().is_some_and(|x| x == "wasm")).collect(); + assert_eq!(stored.len(), 1, "{stored:?}"); + std::fs::write(&stored[0], &other).unwrap(); + vectorcraft_plugins::registry::remove(id); + vectorcraft_plugins::registry::revoke(id); + vectorcraft_plugins::registry::set_storage(id, None); + let no_storage = |how: &str| { + assert!(vectorcraft_plugins::registry::storage(id).is_none(), "{how} gives another module the id's settings and cache"); + }; + let mut next = session(); + next.attach_plugin_store(folder.0.clone()); + assert!(vectorcraft_plugins::registry::get(id).is_none(), "another module isn't loaded under the grant"); + no_storage("the next start"); + let dir = crate::tests_links::Folder::new("plugin-folder-grant-hash"); + std::fs::write(dir.0.join("other.wasm"), &other).unwrap(); + let r = run(&mut next, "plugin.reload", json!({"path": dir.0.display().to_string()})); + assert_eq!(r["loaded"], json!([])); + let why = r["failed"][0]["error"].as_str().unwrap(); + assert!(why.contains("needs approval"), "{why}"); + no_storage("a folder load"); + let data = vectorcraft_format::base64_encode(&other); + let e = next.execute("plugin.install", &json!({"dataBase64": data, "replace": true})).unwrap_err().to_string(); + assert!(e.contains("`grant`"), "{e}"); + no_storage("a refused install"); + // Granted, it reads the settings the first module set. + run(&mut next, "plugin.install", json!({"dataBase64": data, "grant": ["settings", "cache"], "replace": true})); + run(&mut next, "plugin.run", json!({"id": id, "ids": []})); + + // A kept grant that records no hash, or an empty one, covers no module: the stored module isn't + // installed at the next start and `plugin.inspect` asks for approval. + for entry in [json!({"id": id, "permissions": ["settings", "cache"]}), json!({"id": id, "hash": "", "permissions": ["settings", "cache"]})] { + std::fs::write(folder.0.join("grants.json"), serde_json::to_vec(&json!([entry])).unwrap()).unwrap(); + vectorcraft_plugins::registry::remove(id); + vectorcraft_plugins::registry::revoke(id); + let mut again = session(); + again.attach_plugin_store(folder.0.clone()); + assert!(vectorcraft_plugins::registry::get(id).is_none(), "a grant without a hash doesn't load the module: {entry}"); + assert_eq!(run(&mut again, "plugin.inspect", json!({"dataBase64": data}))["needsApproval"], json!(true), "{entry}"); + next = again; + } + run(&mut next, "plugin.install", json!({"dataBase64": data, "grant": ["settings", "cache"], "replace": true})); + run(&mut next, "plugin.remove", json!({"id": id})); +} + /// A grants file entry this version can't use (a permission it doesn't know, an invalid id) is /// skipped without dropping the other grants, and recording a grant writes it back as it was, /// except an entry for the plug-in being granted or removed. A grants file that doesn't parse as a @@ -1188,7 +1379,7 @@ fn a_filter_run_made_elsewhere_keeps_its_document_data() { let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"filter","budget":"heavy"}}"#); let install = |s: &mut Session, src: String| { let data = vectorcraft_format::base64_encode(&wat::parse_str(src).unwrap()); - run(s, "plugin.install", json!({"dataBase64": data, "allowHeavy": true})); + run(s, "plugin.install", json!({"dataBase64": data, "allowHeavy": true, "replace": true})); }; // It first keeps the pens it found with the document. install(&mut s, tpl::returning(&manifest, &json!({"objects": [], "documentData": {"pens": 2}}).to_string())); @@ -1254,3 +1445,379 @@ fn a_cancelled_filter_run_changes_nothing() { assert!(matches!(r, Err(EngineError::Cancelled)), "{r:?}"); assert_eq!(s.doc().unwrap().doc, before); } + +/// A filter `id` of API level 2 that lists `settings` and `cache`, whose `vc_run` runs `first` +/// (instructions that trap unless each storage call returns what it should), with the keys "zoom" +/// at 0x2000 and "tile" at 0x2004 and the value "hello" at 0x2100. +fn storing(id: &str, first: &str) -> Vec { + let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","kind":"filter","api":2,"permissions":["settings","cache"]}}"#); + let data = format!(r#"{} (data (i32.const 0x2000) "zoomtile") (data (i32.const 0x2100) "hello")"#, tpl::STORAGE_IMPORTS); + wat::parse_str(tpl::with_imports(&manifest, &data, first, r#"{"objects":[]}"#)).unwrap() +} + +/// Sets "zoom" to "hello" and writes "hel" to the cache entry "tile". +const WRITES: &str = "(if (call $vc_settings_set (i32.const 0x2000) (i32.const 4) (i32.const 0x2100) (i32.const 5)) (then unreachable)) + (if (call $vc_cache_write (i32.const 0x2004) (i32.const 4) (i32.const 0x2100) (i32.const 3)) (then unreachable))"; + +/// Traps unless "zoom" is "hello". +const READS: &str = + "(if (i32.ne (call $vc_settings_get (i32.const 0x2000) (i32.const 4) (i32.const 0x3000) (i32.const 16)) (i32.const 5)) (then unreachable)) + (if (i32.ne (i32.load (i32.const 0x3000)) (i32.load (i32.const 0x2100))) (then unreachable))"; + +fn install_storing(s: &mut Session, id: &str, first: &str) -> Value { + let data = vectorcraft_format::base64_encode(&storing(id, first)); + run(s, "plugin.install", json!({"dataBase64": data, "grant": ["settings", "cache"]})) +} + +/// [`install_storing`] in place of another module with the same id (`replace: true`). +fn replace_storing(s: &mut Session, id: &str, first: &str) -> Value { + let data = vectorcraft_format::base64_encode(&storing(id, first)); + run(s, "plugin.install", json!({"dataBase64": data, "grant": ["settings", "cache"], "replace": true})) +} + +/// A plug-in's settings and cache are kept in the plug-in store under its id: the next session +/// has them, replacing the plug-in keeps them, another plug-in can't read them, `plugin.clearCache` +/// empties the cache alone and `plugin.remove` deletes both. The caps and the bytes each call is +/// charged follow ([`plugin_storage_is_capped_and_survives_a_damaged_settings_file`], +/// [`plugin_storage_calls_are_charged_the_files_they_read_and_write`]) in the same test, since +/// clearing every plug-in's cache would empty theirs. +#[test] +fn plugins_keep_settings_and_a_cache_of_their_own_in_the_store() { + let folder = crate::tests_links::Folder::new("plugin-storage"); + let (id, other) = ("test.engine.storing", "test.engine.storing.other"); + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + install_storing(&mut s, id, WRITES); + run(&mut s, "plugin.run", json!({"id": id, "ids": []})); + let dir = crate::store::plugin_data_dir(&folder.0, id); + assert!(dir.join("settings.json").is_file()); + assert_eq!(run(&mut s, "plugin.info", json!({"id": id}))["cacheBytes"], json!(3)); + + // A restart: installed again from the store, and replaced by a module that reads. + vectorcraft_plugins::registry::remove(id); + let mut next = session(); + next.attach_plugin_store(folder.0.clone()); + let listed = run(&mut next, "plugin.list", json!({})); + let entry = listed["plugins"].as_array().unwrap().iter().find(|p| p["id"] == id).cloned().unwrap(); + assert_eq!(entry["cacheBytes"], json!(3)); + assert_eq!(replace_storing(&mut next, id, READS)["cacheBytes"], json!(3)); + run(&mut next, "plugin.run", json!({"id": id, "ids": []})); + + // Another plug-in has settings of its own: "zoom" isn't set there. + install_storing(&mut next, other, READS); + assert!(next.execute("plugin.run", &json!({"id": other, "ids": []})).is_err()); + + // Clearing the cache leaves the settings. + assert_eq!(run(&mut next, "plugin.clearCache", json!({"id": id})), json!({"cleared": [id], "bytesFreed": 3})); + assert_eq!(run(&mut next, "plugin.info", json!({"id": id}))["cacheBytes"], json!(0)); + run(&mut next, "plugin.run", json!({"id": id, "ids": []})); + replace_storing(&mut next, other, WRITES); + run(&mut next, "plugin.run", json!({"id": other, "ids": []})); + let all = run(&mut next, "plugin.clearCache", json!({})); + let cleared = all["cleared"].as_array().unwrap(); + assert!(cleared.contains(&json!(id)) && cleared.contains(&json!(other)) && all["bytesFreed"] == json!(3), "{all}"); + assert_eq!(run(&mut next, "plugin.info", json!({"id": other}))["cacheBytes"], json!(0)); + assert!(next.execute("plugin.clearCache", &json!({"id": "test.engine.storing.missing"})).is_err()); + + run(&mut next, "plugin.remove", json!({"id": id})); + assert!(!dir.exists(), "removed with the plug-in"); + run(&mut next, "plugin.remove", json!({"id": other})); + plugin_storage_is_capped_and_survives_a_damaged_settings_file(); + plugin_storage_calls_are_charged_the_files_they_read_and_write(); +} + +/// A `storage` folder in the plug-in store that is a link leads out of the store: the plug-in +/// installs without `replace` although a folder for its id is there, its storage calls fail as +/// unavailable, and removing the plug-in deletes nothing the link leads to. +#[test] +fn plugin_storage_behind_a_link_is_left_alone() { + let folder = crate::tests_links::Folder::new("plugin-storage-link"); + let outside = crate::tests_links::Folder::new("plugin-storage-outside"); + #[cfg(unix)] + let made = std::os::unix::fs::symlink(&outside.0, folder.0.join("storage")).is_ok(); + // Without Developer Mode or elevation Windows makes no symbolic link, but a junction. + #[cfg(windows)] + let made = std::os::windows::fs::symlink_dir(&outside.0, folder.0.join("storage")).is_ok() + || std::process::Command::new("cmd") + .args(["/C", "mklink", "/J"]) + .arg(folder.0.join("storage")) + .arg(&outside.0) + .output() + .is_ok_and(|o| o.status.success()); + #[cfg(not(any(unix, windows)))] + let made = false; + if !made { + eprintln!("skipped: symbolic links can't be made here"); + return; + } + let id = "test.engine.storagelink"; + let kept = crate::store::plugin_data_dir(&folder.0, id).join("settings.json"); + std::fs::create_dir_all(kept.parent().unwrap()).unwrap(); + std::fs::write(&kept, b"{}").unwrap(); + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + install_storing(&mut s, id, ""); + let st = vectorcraft_plugins::registry::storage(id).unwrap(); + let m = &mut vectorcraft_plugins::storage::Meter::unlimited(); + assert_eq!(st.set_setting("zoom", b"hello", m), Err(vectorcraft_plugins::storage::StorageError::Unavailable)); + run(&mut s, "plugin.remove", json!({"id": id})); + assert!(kept.is_file(), "left where the link leads"); +} + +/// `plugin.remove` with the id of a plug-in that isn't installed deletes the settings and cache the +/// plug-in store still keeps for it; with neither, it fails as before. +#[test] +fn removing_an_id_that_is_not_installed_deletes_its_storage() { + let folder = crate::tests_links::Folder::new("plugin-storage-orphan"); + let id = "test.engine.storageorphan"; + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + let dir = crate::store::plugin_data_dir(&folder.0, id); + std::fs::create_dir_all(dir.join("cache")).unwrap(); + std::fs::write(dir.join("settings.json"), b"{}").unwrap(); + std::fs::write(dir.join("cache").join("tile.bin"), b"abc").unwrap(); + assert!(vectorcraft_plugins::registry::get(id).is_none()); + assert_eq!(run(&mut s, "plugin.remove", json!({"id": id})), json!({"removed": id})); + assert!(!dir.exists(), "the storage of an id that isn't installed is deleted"); + assert!(s.execute("plugin.remove", &json!({"id": id})).is_err(), "nothing left to remove"); +} + +/// The settings file and the cache are checked: a file that doesn't read is moved aside and the +/// settings start empty; a write past a cap fails and changes nothing; cache entries whose names +/// differ only in case are two files; a removed plug-in's storage takes no more writes. +fn plugin_storage_is_capped_and_survives_a_damaged_settings_file() { + use vectorcraft_plugins::storage::{self, Meter, StorageError}; + let folder = crate::tests_links::Folder::new("plugin-storage-caps"); + let id = "test.engine.storagecaps"; + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + install_storing(&mut s, id, ""); + let st = vectorcraft_plugins::registry::storage(id).unwrap(); + let dir = crate::store::plugin_data_dir(&folder.0, id); + std::fs::create_dir_all(&dir).unwrap(); + let m = &mut Meter::unlimited(); + + // A truncated settings file. + std::fs::write(dir.join("settings.json"), br#"{"zoom": "aGVsbG"#).unwrap(); + assert_eq!(st.setting("zoom", m).unwrap(), None); + assert!(dir.join("settings.unreadable.json").is_file() && !dir.join("settings.json").exists()); + st.set_setting("zoom", b"hello", m).unwrap(); + assert_eq!(st.setting("zoom", m).unwrap(), Some(b"hello".to_vec())); + // Damaged again, it replaces the file moved aside before. + std::fs::write(dir.join("settings.json"), b"[").unwrap(); + assert_eq!(st.setting("zoom", m).unwrap(), None); + assert_eq!(std::fs::read(dir.join("settings.unreadable.json")).unwrap(), b"["); + st.set_setting("zoom", b"hello", m).unwrap(); + plugin_storage_writes_are_atomic(&*st, &dir, &folder.0); + + // At most 256 keys: a new one is refused, an existing one still changes. + let keys: std::collections::BTreeMap = (0..storage::MAX_SETTINGS).map(|i| (format!("k{i}"), "eA==".to_string())).collect(); + std::fs::write(dir.join("settings.json"), serde_json::to_vec(&keys).unwrap()).unwrap(); + assert_eq!(st.set_setting("one-more", b"x", m), Err(StorageError::Full)); + st.set_setting("k0", b"y", m).unwrap(); + assert_eq!(st.setting("k0", m).unwrap(), Some(b"y".to_vec())); + plugin_storage_skips_oversize_files(&*st, &dir); + + // At most 1 MiB in all: eleven values of 64 KiB fit, a twelfth doesn't, and nothing changes. + let big = vec![7_u8; storage::MAX_SETTING_BYTES]; + let keys: std::collections::BTreeMap = (0..11).map(|i| (format!("k{i}"), vectorcraft_format::base64_encode(&big))).collect(); + std::fs::write(dir.join("settings.json"), serde_json::to_vec(&keys).unwrap()).unwrap(); + assert_eq!(st.set_setting("k11", &big, m), Err(StorageError::Full)); + assert_eq!((st.setting("k11", m).unwrap(), st.setting("k0", m).unwrap()), (None, Some(big.clone()))); + + // The cache: entry size, then the total, here nearly reached by a file already there. + assert_eq!(st.set_cache_entry("big", &vec![0; storage::MAX_CACHE_ENTRY + 1], m), Err(StorageError::Full)); + std::fs::create_dir_all(dir.join("cache")).unwrap(); + std::fs::File::create(dir.join("cache").join("filler.bin")).unwrap().set_len(storage::MAX_CACHE_BYTES - 2).unwrap(); + // A temporary file a failed write left behind counts toward no cap, and clearing removes it. + let leftover = dir.join("cache").join(".tile.bin.1-0.tmp"); + std::fs::write(&leftover, b"tmp").unwrap(); + assert_eq!(st.set_cache_entry("tile", b"abc", m), Err(StorageError::Full)); + assert_eq!(st.cache_entry("tile", m).unwrap(), None); + st.set_cache_entry("tile", b"ab", m).unwrap(); + assert_eq!(st.cache_bytes(), storage::MAX_CACHE_BYTES); + assert_eq!(st.clear_cache().unwrap(), storage::MAX_CACHE_BYTES + 3); + assert!(!leftover.exists()); + + // Names differing in case are two entries. + st.set_cache_entry("Tile", b"1", m).unwrap(); + st.set_cache_entry("tile", b"22", m).unwrap(); + assert_eq!((st.cache_entry("Tile", m).unwrap(), st.cache_entry("tile", m).unwrap()), (Some(b"1".to_vec()), Some(b"22".to_vec()))); + assert_eq!(st.cache_bytes(), 3); + + // More items in the cache folder than a write looks at: the write is refused whatever the + // items it looked at hold, and clearing still reaches every entry. + let cache = dir.join("cache"); + for i in 0..crate::store::MAX_CACHE_SCAN { + std::fs::create_dir(cache.join(format!("d{i:05}"))).unwrap(); + } + std::fs::File::create(cache.join("z.bin")).unwrap().set_len(storage::MAX_CACHE_BYTES).unwrap(); + assert_eq!(st.set_cache_entry("more", b"1", m), Err(StorageError::Full)); + assert_eq!(st.clear_cache().unwrap(), storage::MAX_CACHE_BYTES + 3); + assert!(!cache.join("z.bin").exists()); + + // An id that differs only in case has storage of its own, which removing this one leaves. + let upper_id = "test.engine.StorageCaps"; + s.attach_plugin_storage(upper_id); + let upper = vectorcraft_plugins::registry::storage(upper_id).unwrap(); + assert_eq!(upper.setting("k0", m).unwrap(), None); + upper.set_setting("k0", b"upper", m).unwrap(); + assert_eq!(st.setting("k0", m).unwrap(), Some(big)); + // So do ids whose `_` and `.` could be read as each other's escapes. + let folders: std::collections::BTreeSet<_> = + ["a.b", "a_0b", "a_b", "A.b", "a__b", "a._b", "a_.b"].iter().map(|id| crate::store::plugin_data_dir(&folder.0, id)).collect(); + assert_eq!(folders.len(), 7, "{folders:?}"); + + // Removing the plug-in closes the storage a run still holds, even one from before the plug-in + // was replaced, so nothing is written back. + s.attach_plugin_storage(id); + run(&mut s, "plugin.remove", json!({"id": id})); + assert_eq!(st.set_setting("zoom", b"again", m), Err(StorageError::Unavailable)); + assert_eq!(st.set_cache_entry("tile", b"again", m), Err(StorageError::Unavailable)); + assert!(!dir.exists()); + assert_eq!(upper.setting("k0", m).unwrap(), Some(b"upper".to_vec())); + s.remove_plugin_storage(upper_id).unwrap(); +} + +/// Files larger than the storage reads are not read: a settings file larger than 1 MiB, although +/// each of its values would fit, is moved aside and the settings start empty; a cache entry file +/// larger than an entry may be fails as unavailable. `st` is the storage in `dir`. +fn plugin_storage_skips_oversize_files(st: &dyn vectorcraft_plugins::storage::Storage, dir: &std::path::Path) { + use vectorcraft_plugins::storage::{self, Meter, StorageError}; + let m = &mut Meter::unlimited(); + let value = vectorcraft_format::base64_encode(&vec![7_u8; storage::MAX_SETTING_BYTES]); + let keys: std::collections::BTreeMap = (0..13).map(|i| (format!("k{i}"), value.clone())).collect(); + let oversize = serde_json::to_vec(&keys).unwrap(); + assert!(oversize.len() > storage::MAX_SETTINGS_BYTES); + std::fs::write(dir.join("settings.json"), &oversize).unwrap(); + assert_eq!(st.setting("k0", m).unwrap(), None); + assert!(dir.join("settings.unreadable.json").is_file() && !dir.join("settings.json").exists()); + std::fs::create_dir_all(dir.join("cache")).unwrap(); + let huge = dir.join("cache").join("huge.bin"); + std::fs::File::create(&huge).unwrap().set_len(storage::MAX_CACHE_ENTRY as u64 + 1).unwrap(); + assert_eq!(st.cache_entry("huge", m), Err(StorageError::Unavailable)); + std::fs::remove_file(&huge).unwrap(); +} + +/// Writes are atomic: a new file takes the old one's name, so another name for the old file (a hard +/// link) keeps its content. `st` is the storage in `dir` and holds the setting "zoom"; `outside` is +/// a folder outside it. +fn plugin_storage_writes_are_atomic(st: &dyn vectorcraft_plugins::storage::Storage, dir: &std::path::Path, outside: &std::path::Path) { + let m = &mut vectorcraft_plugins::storage::Meter::unlimited(); + let settings_copy = outside.join("settings-copy.json"); + let before = std::fs::read(dir.join("settings.json")).unwrap(); + std::fs::hard_link(dir.join("settings.json"), &settings_copy).unwrap(); + st.set_setting("zoom", b"world", m).unwrap(); + assert_eq!(std::fs::read(&settings_copy).unwrap(), before); + assert_eq!(st.setting("zoom", m).unwrap(), Some(b"world".to_vec())); + st.set_cache_entry("tile", b"abc", m).unwrap(); + let entry_copy = outside.join("tile-copy.bin"); + let entry = std::fs::read_dir(dir.join("cache")).unwrap().map(|e| e.unwrap().path()).find(|p| p.is_file()).unwrap(); + std::fs::hard_link(&entry, &entry_copy).unwrap(); + st.set_cache_entry("tile", b"xyz", m).unwrap(); + assert_eq!(std::fs::read(&entry_copy).unwrap(), b"abc"); + assert_eq!(st.cache_entry("tile", m).unwrap(), Some(b"xyz".to_vec())); + assert!(st.remove_cache_entry("tile").unwrap()); + std::fs::remove_file(&settings_copy).unwrap(); + std::fs::remove_file(&entry_copy).unwrap(); +} + +/// Each storage call is charged the bytes it reads and writes, before it does: a settings call the +/// whole settings file it reads and the whole file it writes, a cache call the entry it reads or +/// writes. A call that would pass what its meter has left fails and changes nothing. +fn plugin_storage_calls_are_charged_the_files_they_read_and_write() { + use vectorcraft_plugins::storage::{Meter, StorageError}; + let folder = crate::tests_links::Folder::new("plugin-storage-meter"); + let id = "test.engine.storagemeter"; + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + install_storing(&mut s, id, ""); + let st = vectorcraft_plugins::registry::storage(id).unwrap(); + let file = crate::store::plugin_data_dir(&folder.0, id).join("settings.json"); + let size = || std::fs::metadata(&file).unwrap().len(); + st.set_setting("big", &[7; 60_000], &mut Meter::unlimited()).unwrap(); + st.set_setting("small", b"x", &mut Meter::unlimited()).unwrap(); + + // Reading a one-byte setting reads the whole file. + let before = size(); + let mut m = Meter::unlimited(); + assert_eq!(st.setting("small", &mut m).unwrap(), Some(b"x".to_vec())); + assert_eq!(m.used(), before); + // Setting it reads the file and writes it again, and so does deleting one. + let mut m = Meter::unlimited(); + st.set_setting("small", b"yz", &mut m).unwrap(); + assert_eq!(m.used(), before + size()); + let (before, mut m) = (size(), Meter::unlimited()); + assert!(st.delete_setting("small", &mut m).unwrap()); + assert_eq!(m.used(), before + size()); + st.set_setting("small", b"y", &mut Meter::unlimited()).unwrap(); + + // Less left than the file: nothing is read. Enough to read it but not to write it again: + // nothing changes. + let mut m = Meter::new(size() - 1); + assert_eq!(st.setting("small", &mut m), Err(StorageError::Budget)); + assert_eq!(m.used(), 0); + assert_eq!(st.set_setting("small", b"z", &mut Meter::new(size())), Err(StorageError::Budget)); + assert_eq!(st.setting("small", &mut Meter::unlimited()).unwrap(), Some(b"y".to_vec())); + + // A cache write is charged the entry it writes, a read the entry it reads. + let mut m = Meter::unlimited(); + st.set_cache_entry("tile", &[1; 1000], &mut m).unwrap(); + assert_eq!(m.used(), 1000); + let mut m = Meter::unlimited(); + assert_eq!(st.cache_entry("tile", &mut m).unwrap().map(|v| v.len()), Some(1000)); + assert_eq!(m.used(), 1000); + assert_eq!(st.cache_entry("tile", &mut Meter::new(999)), Err(StorageError::Budget)); + assert_eq!(st.set_cache_entry("tile", &[2; 10], &mut Meter::new(9)), Err(StorageError::Budget)); + assert_eq!(st.cache_entry("tile", &mut Meter::unlimited()).unwrap().map(|v| v.len()), Some(1000)); + run(&mut s, "plugin.remove", json!({"id": id})); +} + +/// A module with other bytes than the plug-in installed with its id, or, with none installed, one +/// whose id has settings and a cache in the plug-in store, installs only with `replace: true`, and +/// then keeps them; the same module installs again without it, and `replace` must be true or false. +#[test] +fn another_module_under_an_id_with_settings_needs_replace() { + let folder = crate::tests_links::Folder::new("plugin-storage-replace"); + let id = "test.engine.storingreplace"; + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + install_storing(&mut s, id, WRITES); + run(&mut s, "plugin.run", json!({"id": id, "ids": []})); + let hash = run(&mut s, "plugin.info", json!({"id": id}))["hash"].clone(); + // The same module again. + install_storing(&mut s, id, WRITES); + + let reads = vectorcraft_format::base64_encode(&storing(id, READS)); + let install = |s: &mut Session, extra: Value| { + let mut p = json!({"dataBase64": reads, "grant": ["settings", "cache"]}); + p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + s.execute("plugin.install", &p) + }; + let Err(e) = install(&mut s, json!({})) else { panic!("another module took the plug-in's place") }; + assert!(e.to_string().contains(&format!("“Test {id}” is another module than the plug-in with id \"{id}\"")), "{e}"); + assert!(e.to_string().contains("`replace: true`"), "{e}"); + assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": reads}))["needsReplace"], json!(true)); + assert_eq!(run(&mut s, "plugin.info", json!({"id": id}))["hash"], hash); + for bad in [json!({"replace": "yes"}), json!({"replace": 1})] { + let Err(e) = install(&mut s, bad) else { panic!("a bad `replace` was taken") }; + assert!(e.to_string().contains("`replace` must be true or false"), "{e}"); + } + assert!(install(&mut s, json!({"replace": false})).is_err()); + install(&mut s, json!({"replace": true})).unwrap(); + // It reads what the one it replaced set. + run(&mut s, "plugin.run", json!({"id": id, "ids": []})); + assert_eq!(run(&mut s, "plugin.info", json!({"id": id}))["cacheBytes"], json!(3)); + + // Not installed, but with settings in the store: another module needs `replace` too. + vectorcraft_plugins::registry::remove(id); + let writes = vectorcraft_format::base64_encode(&storing(id, WRITES)); + assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": writes}))["needsReplace"], json!(true)); + assert!(s.execute("plugin.install", &json!({"dataBase64": writes, "grant": ["settings", "cache"]})).is_err()); + run(&mut s, "plugin.install", json!({"dataBase64": writes, "grant": ["settings", "cache"], "replace": true})); + run(&mut s, "plugin.remove", json!({"id": id})); + // Once removed, with its settings and cache, it installs as a first install does. + assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": reads}))["needsReplace"], json!(false)); + install(&mut s, json!({})).unwrap(); + run(&mut s, "plugin.remove", json!({"id": id})); +} diff --git a/crates/engine/src/tests/select_ids_first.rs b/crates/engine/src/tests/select_ids_first.rs index da6c83c87..35d5165cd 100644 --- a/crates/engine/src/tests/select_ids_first.rs +++ b/crates/engine/src/tests/select_ids_first.rs @@ -208,7 +208,7 @@ fn id_errors_name_the_command_run() { for (given, want) in [ (json!([999]), "no such object id 999".to_string()), (json!([999, layer]), "no such object id 999".to_string()), - (json!([ids[0], ids[1], -1]), "invalid object id -1: expected a non-negative integer".to_string()), + (json!([ids[0], ids[1], -1]), "`ids` entry 2 is -1, not a non-negative integer object id".to_string()), (json!(ids[0]), "ids must be an array of non-negative integer object ids".to_string()), ] { let mut p = match *cmd { diff --git a/crates/engine/src/tests/svgfilter.rs b/crates/engine/src/tests/svgfilter.rs index c9f5cef44..fcb4581c2 100644 --- a/crates/engine/src/tests/svgfilter.rs +++ b/crates/engine/src/tests/svgfilter.rs @@ -461,6 +461,25 @@ fn svg_export_writes_the_filter_when_it_is_the_last_effect() { assert!(!out.contains(""#); + let xml = format!("{open}{}{close}", "a".repeat(vectorcraft_doc::SvgFilter::MAX_XML - open.len() - close.len())); + run(&mut s, "effect.apply", json!({"effect": "svg.filter", "ids": [id.0], "params": {"xml": xml}})); + assert_eq!(&render(&s).pixel(100, 100)[..3], &[255, 0, 0]); + let out = svg(&mut s); + assert!(out.contains(" 240 && px[1] < 15 && px[2] < 15, "{px:?}"); +} + /// A filter SVG 1.1 can't hold (a blend mode SVG 1.1 lacks) is exported as an image, as the canvas /// draws it; an SVG 1.1 one stays a filter. #[test] diff --git a/crates/engine/src/tests/swatchcmds.rs b/crates/engine/src/tests/swatchcmds.rs index 2a74e194d..cbf595d6d 100644 --- a/crates/engine/src/tests/swatchcmds.rs +++ b/crates/engine/src/tests/swatchcmds.rs @@ -503,7 +503,7 @@ fn a_cmyk_document_starts_with_cmyk_swatches_and_stores_new_colours_as_cmyk() { // A swatch made from a hex colour is CMYK as well; the document round-trips natively. let name = run(&mut s, "swatch.new", json!({"color": "#00ff00"}))["name"].as_str().unwrap().to_string(); assert_eq!(doc(&s).swatch(&name).and_then(|w| w.paint.color()), Some(managed(Color::rgb(0.0, 1.0, 0.0)))); - assert_eq!(name, "C=61 M=0 Y=100 K=0"); + assert_eq!(name, "C=65 M=0 Y=100 K=0"); let json = serde_json::to_string(doc(&s)).unwrap(); assert_eq!(serde_json::from_str::(&json).unwrap(), *doc(&s)); } diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index a895655b9..885ce09e1 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -1276,6 +1276,45 @@ fn start_list_command_removes_the_typed_characters_and_starts_the_list() { assert!(s.execute("text.startList", &json!({"at": 3})).is_err(), "id required"); } +/// A list typed with "1. " starts at level 1. Tab at the start of the item Return makes moves it +/// to level 2, where it is numbered from 1, Return continues the list at that level, and +/// Shift+Tab moves an item back up, where it goes on with level 1's count. "1. " typed at the +/// start of an item at level 2 stays text. +#[test] +fn a_typed_list_takes_its_levels_from_tab_and_shift_tab() { + use vectorcraft_doc::text::{NumberStyle, ParaList}; + let v = ViewInfo::default(); + let mut s = session(); + s.select_tool("type", v).unwrap(); + click(&mut s, 200.0, 200.0); + let id = s.doc().unwrap().selection.objects[0]; + let typed = |s: &mut Session, text: &str| { + for c in text.chars() { + s.tool_text(&c.to_string(), v).unwrap(); + } + }; + let shift = Mods { shift: true, ..Mods::default() }; + typed(&mut s, "1. Apples"); + key(&mut s, ToolKey::Enter, Mods::default()); + key(&mut s, ToolKey::Tab, Mods::default()); + typed(&mut s, "Pears"); + key(&mut s, ToolKey::Enter, Mods::default()); + typed(&mut s, "Plums"); + key(&mut s, ToolKey::Enter, Mods::default()); + key(&mut s, ToolKey::Tab, shift); + typed(&mut s, "Figs"); + let arabic = ParaList::numbered(NumberStyle::Arabic); + let t = obj(&s, id); + assert_eq!(t.plain_text(), "Apples\nPears\nPlums\nFigs"); + assert_eq!((0..4).map(|i| t.para_at(i).list).collect::>(), [arabic, arabic.at_level(2), arabic.at_level(2), arabic].map(Some)); + assert_eq!(list_markers(&s, id), ["1.", "1.", "2.", "2."]); + key(&mut s, ToolKey::Enter, Mods::default()); + key(&mut s, ToolKey::Tab, Mods::default()); + typed(&mut s, "1. "); + let t = obj(&s, id); + assert_eq!((t.plain_text().as_str(), t.para_at(4).list), ("Apples\nPears\nPlums\nFigs\n1. ", Some(arabic.at_level(2)))); +} + /// A byte offset given as a negative or fractional number is an error, not the end of the text /// (`at`) or its start or end (`start`, `end`), and the text is left as it was. Null keeps the default. #[test] @@ -2318,6 +2357,18 @@ fn convert_area_point_keeps_text_in_place_for_every_alignment() { } } +#[test] +fn convert_area_to_point_keeps_a_deeper_list_item_in_place() { + let mut s = session(); + let r = s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Item", "size": 18, "area": {"width": 150, "height": 200}})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("text.setFormat", &json!({"ids": [id.0], "list": "bullet", "listLevel": 3})).unwrap(); + let before = first_glyph_doc_pos(&obj(&s, id)); + s.execute("type.convertToPointType", &json!({"ids": [id.0]})).unwrap(); + let after = first_glyph_doc_pos(&obj(&s, id)); + assert!((after - before).hypot() < 0.01, "{before:?} → {after:?}"); +} + #[test] fn commands_are_listed_with_params() { let s = session(); diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index fa389583f..28b014ee5 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -219,6 +219,7 @@ impl Session { // The Selection and Group Selection tools act on the instance whole. self.select_whole_instance(id); let lent = self.make_tool(id); + self.selection_at_lend = self.active().map(|d| (d.uid, d.selection.clone())); self.lender = Some(std::mem::replace(&mut self.tool, lent)); PointerEvent { mods: Mods { cmd: false, ..ev.mods }, ..*ev } } @@ -232,6 +233,12 @@ impl Session { let r = self.apply_actions(acts); self.keep_tool_settings(); self.tool = lender; + // A gesture that left the selection as it found it (a Shift marquee over nothing) picked + // nothing: the selection keeps what it was, the Pen's last anchor or an earlier pick. + let left = self.active().map(|d| (d.uid, d.selection.clone())); + if left != self.selection_at_lend.take() { + self.lent_selection = left; + } self.after_command(None); r.map(drop) } @@ -333,6 +340,7 @@ impl Session { unit, stroke_unit, paste_plain_text: self.prefs.paste_text_formatting == "plain", + typed_from_keyboard: self.keyboard_text, slices_hidden: self.menu.slices_hidden, slices_locked: self.menu.slices_locked, auto_add_delete: !self.prefs.disable_auto_add_delete, @@ -616,6 +624,17 @@ impl Session { self.apply_actions(acts) } + /// Text typed on the keyboard (a key or an IME commit) for the active tool: as + /// [`Session::tool_text`], and Typographer's Quotes may also set a lone typed `”` as an opening + /// quote ([`vectorcraft_tools::ToolContext::typed_from_keyboard`]). The Glyphs panel and pastes + /// use [`Session::tool_text`], so their quotes stay as inserted. + pub fn tool_typed_text(&mut self, text: &str, view: ViewInfo) -> Result> { + self.keyboard_text = true; + let r = self.tool_text(text, view); + self.keyboard_text = false; + r + } + /// Would [`Session::tool_text`] insert `text` unchanged (no control-character filtering, /// newline normalization or document formatting such as Typographer's Quotes)? An IME commit /// the application rewrites is an application edit, not an accepted IME insertion. @@ -623,6 +642,16 @@ impl Session { self.with_tool_cx(view, |t, cx| t.text_input_is_verbatim(cx, text)) } + /// [`Session::tool_text_is_verbatim`] for text typed on the keyboard, as + /// [`Session::tool_typed_text`] would insert it: a lone typed `”` that Typographer's Quotes + /// turns into an opening quote is not verbatim. + pub fn tool_typed_text_is_verbatim(&mut self, text: &str, view: ViewInfo) -> bool { + self.keyboard_text = true; + let v = self.tool_text_is_verbatim(text, view); + self.keyboard_text = false; + v + } + /// Does the active tool's open typing session still match the document? An IME commit after /// an external edit starts a fresh session (an application-side reset), so its receipt must /// not be accepted as an unchanged IME insertion. diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index aca3d27a8..863a574cd 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -230,6 +230,10 @@ fn structured_junk() { ("document.export", json!({"format": "bmp", "rect": {"x": 1}, "artboard": 3})), ("document.export", json!({"format": "svg", "rect": [0, 0, 10, 10]})), ("select.add", json!({"ids": [[1], {"a": 1}]})), + ("select.font", json!({"family": "Source Sans 3", "scope": {"artboards": [0, u64::MAX, -1, "x", null, 1.5]}})), + ("select.font", json!({"family": "Source Sans 3", "scope": {"layers": [0, u64::MAX, -1, [1], {"a": 1}]}})), + ("select.font", json!({"family": "Source Sans 3", "scope": {"artboards": [], "layers": [1]}})), + ("select.font", json!({"family": "Source Sans 3", "scope": 7})), ("select.anchors", json!({"id": 2, "anchors": [[99, 99], [-1, 0], ["a", "b"]], "mode": "set"})), ("select.anchorsMany", json!({"items": [{"id": 2, "anchors": [[0, 1000]]}, null, 5]})), ("select.anchorsMany", json!({"items": [{"id": 2, "anchors": [[0, 1000], [0, 0]]}, {"id": u64::MAX}], "mode": "toggle"})), diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index f72240edf..d46688bdf 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -2381,6 +2381,53 @@ proptest! { ); survive("TIFF-predicted EPS images", || vectorcraft_eps::import(text.as_bytes()).ok().map(|r| r.document))?; } + + /// Damaged or hostile TrueType data in a Type 42 font that a composite font shows (its table + /// directory and its glyphs), any glyph index: drawn, measured and outlined, or left out. + #[test] + fn eps_type42_glyphs_never_panic( + head in prop::collection::vec((0usize..2048, any::()), 0..12), + body in prop::collection::vec((0usize..TYPE42_FONT.len(), any::()), 0..12), + gid in 0u32..70_000, + ) { + let mut font = TYPE42_FONT.to_vec(); + for (at, b) in head.into_iter().chain(body) { + if let Some(x) = font.get_mut(at) { + *x = b; + } + } + let text = type42_composite_eps(&font, gid); + survive("damaged Type 42 glyphs", || vectorcraft_eps::import(text.as_bytes()).ok().map(|r| r.document))?; + } +} + +/// The TrueType font [`type42_composite_eps`] carries. +const TYPE42_FONT: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf"); + +/// An EPS showing glyph `gid` of TrueType font `font` through a Type 42 font and a composite font +/// over it, and measuring and outlining it. +fn type42_composite_eps(font: &[u8], gid: u32) -> String { + let sfnts: String = font.chunks(32_000).map(|c| format!("<{}>\n", c.iter().map(|b| format!("{b:02x}")).collect::())).collect(); + format!( + "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n\ + 10 dict begin /FontName /T42 def /FontType 42 def /FontMatrix [1 0 0 1 0 0] def /FontBBox [0 0 1 1] def /PaintType 0 def\n\ + /Encoding 256 array def 0 1 255 {{ Encoding exch /.notdef put }} for Encoding 1 /c01 put\n\ + /CharStrings 2 dict def CharStrings begin /.notdef 0 def /c01 {gid} def end /sfnts [\n{sfnts}] def FontName currentdict end definefont pop\n\ + 16 dict begin /FontName /Comp def /FontType 0 def /FontMatrix [1 0 0 1 0 0] def /FMapType 2 def /Encoding [0] def\n\ + /FDepVector [/T42 findfont] def FontName currentdict end definefont pop\n\ + /Comp findfont 40 scalefont setfont 10 20 moveto (\\000\\001\\000\\001) show (\\000\\001) stringwidth pop pop\n\ + 10 60 moveto (\\000\\001) true charpath fill\nshowpage\n%%EOF\n" + ) +} + +/// [`type42_composite_eps`] as it is: the glyph draws as filled outlines, not as type. +#[test] +fn the_type42_fixture_draws_its_glyphs() { + let r = vectorcraft_eps::import(type42_composite_eps(TYPE42_FONT, 9).as_bytes()).unwrap(); + assert!(r.warnings.is_empty() && !r.preview, "{:?}", r.warnings); + let mut kinds = vec![]; + r.document.walk(|n| kinds.push(n.kind_label())); + assert!(!kinds.contains(&"Text") && kinds.iter().filter(|k| **k == "Path").count() >= 3, "{kinds:?}"); } /// An EPS with two 8 × 3 CCITT fax images that the PostScript interpreter reads, coded by hand from @@ -4772,6 +4819,16 @@ fn arb_ai_token() -> impl Strategy { "\n%AI5_BeginRaster\n", "\n%AI5_EndRaster\n", "\n%%BeginData: 12\rXI\n", + // Image operands past 2^25 pixels, of odd 1-bit rows, and past the hard limits: as + // many pixels as a u32 side allows, more samples than the bytes allowed, sizes that + // overflow when multiplied out, and a matrix that maps the box to nothing. + "[1 0 0 1 0 0] 0 0 6000 6000 6000 6000 1 1 0 0 1 0 4 4 0 0 ", + "[1 0 0 1 0 0] 0 0 17 13 17 13 1 1 0 0 1 0 4 4 0 0 ", + "[1 0 0 1 0 0] 0 0 9 9 65536 32769 1 1 0 0 1 0 4 4 0 0 ", + "[1 0 0 1 0 0] 0 0 9 9 23171 23171 8 4 1 0 1 0 4 4 0 0 ", + "[1 0 0 1 0 0] 0 0 9 9 4294967295 4294967295 8 4 1 0 1 0 4 4 0 0 ", + "[1 0 0 1 0 0] 0 0 9 9 1e308 1e308 8 3 1 0 0 0 4 4 0 0 ", + "[0 0 0 0 0 0] 0 0 9 9 46341 46341 1 1 0 0 1 0 4 4 0 0 ", "\n%%EndData\n", "\n%AI5_BeginGradient: (G)\n", "\n%AI14_BeginSymbol\n", diff --git a/crates/engine/tests/plugin_fuzz.rs b/crates/engine/tests/plugin_fuzz.rs index 514118cf3..a7e45f690 100644 --- a/crates/engine/tests/plugin_fuzz.rs +++ b/crates/engine/tests/plugin_fuzz.rs @@ -30,8 +30,9 @@ fn config() -> ProptestConfig { c } +/// Installs `wasm`, in place of any module installed with its id before (`replace: true`). fn install(s: &mut Session, wasm: &[u8]) -> Result { - s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(wasm)})).map_err(|e| e.to_string()) + s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(wasm), "replace": true})).map_err(|e| e.to_string()) } /// The document renders and exports without panicking. @@ -658,4 +659,66 @@ proptest! { prop_assert!(total == 0 || done <= total); } } + + /// A plug-in that calls the storage imports with hostile keys (`..`, separators, device names, + /// control characters, too long), values, lengths and pointers in and out of its memory runs to + /// an output, never a panic; every call returns a length or a storage code, and the settings and + /// cache of another plug-in in the same store stay as they were. + #[test] + fn hostile_storage_arguments_never_crash( + calls in prop::collection::vec( + ( + 0..6usize, + prop_oneof![any::(), 0x1ff00..0x20100i32, Just(0x2000)], + prop_oneof![any::(), 0..80i32], + prop_oneof![any::(), 0x1ff00..0x20100i32, Just(0x3000)], + prop_oneof![any::(), 0..5000i32], + ), + 0..16, + ), + key in prop::collection::vec(prop_oneof![Just(b'a'), Just(b'.'), Just(b'/'), Just(b'\\'), Just(b'_'), any::()], 0..80), + ) { + use vectorcraft_plugins::host::STORAGE_BUDGET; + use vectorcraft_plugins::storage::Meter; + const FUNCS: [&str; 6] = ["vc_settings_get", "vc_settings_set", "vc_cache_read", "vc_cache_write", "vc_settings_delete", "vc_cache_clear"]; + let folder = std::env::temp_dir().join(format!("vectorcraft-plugin-fuzz-storage-{}", std::process::id())); + let mut s = Session::new(); + s.attach_plugin_store(folder.clone()); + let storing = |id: &str| format!(r#"{{"id":"{id}","name":"F","kind":"filter","api":2,"permissions":["settings","cache"]}}"#); + let grant = json!(["settings", "cache"]); + let other = wat::parse_str(tpl::returning(&storing("fuzz.storage.other"), r#"{"objects":[]}"#)).unwrap(); + s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&other), "grant": grant})).unwrap(); + let theirs = vectorcraft_plugins::registry::storage("fuzz.storage.other").unwrap(); + let m = &mut Meter::unlimited(); + theirs.set_setting("mine", b"kept", m).unwrap(); + theirs.set_cache_entry("mine", b"kept", m).unwrap(); + // Each call's result is checked: a length (0 or more) or one of the storage codes. + let check = |call: String| format!("(local.set $r {call}) (if (i32.lt_s (local.get $r) (i32.const {STORAGE_BUDGET})) (then unreachable))"); + let body: String = calls + .iter() + .map(|&(f, kp, kl, vp, vl)| match f { + 0..4 => check(format!("(call ${} (i32.const {kp}) (i32.const {kl}) (i32.const {vp}) (i32.const {vl}))", FUNCS[f])), + _ => check(format!("(call ${} (i32.const {kp}) (i32.const {kl}))", FUNCS[f])), + }) + .collect(); + let text: String = key.iter().map(|b| format!("\\{b:02x}")).collect(); + let imports = format!(r#"{} (data (i32.const 0x2000) "{text}")"#, tpl::STORAGE_IMPORTS); + let wasm = wat::parse_str(tpl::with_imports(&storing("fuzz.storage"), &imports, &format!("(local $r i32) {body}"), r#"{"objects":[]}"#)).unwrap(); + let r = catch_quiet(|| { + s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": grant})).map_err(|e| e.to_string())?; + let p = vectorcraft_plugins::registry::get("fuzz.storage").ok_or("not installed")?; + p.run(b"{\"objects\":[]}", b"{}").map_err(|e| e.to_string()) + }); + let out = r.map_err(|msg| TestCaseError::fail(format!("panicked on {calls:?}: {msg}")))?; + prop_assert!(out.is_ok(), "{:?}", out); + prop_assert_eq!(theirs.setting("mine", m).unwrap(), Some(b"kept".to_vec())); + prop_assert_eq!(theirs.cache_entry("mine", m).unwrap(), Some(b"kept".to_vec())); + prop_assert_eq!(theirs.cache_bytes(), 4); + // The host names every file: the store holds the two modules, their grants and their folders. + let names = |dir: &std::path::Path| std::fs::read_dir(dir).unwrap().filter_map(Result::ok).map(|e| e.file_name()).collect::>(); + prop_assert!(names(&folder).len() <= 4, "{:?}", names(&folder)); + prop_assert!(names(&folder.join("storage")).len() <= 2, "{:?}", names(&folder.join("storage"))); + let _ = s.execute("plugin.remove", &json!({"id": "fuzz.storage"})); + let _ = s.execute("plugin.remove", &json!({"id": "fuzz.storage.other"})); + } } diff --git a/crates/engine/tests/plugin_safe_mode.rs b/crates/engine/tests/plugin_safe_mode.rs index 6923df616..7fe54fb53 100644 --- a/crates/engine/tests/plugin_safe_mode.rs +++ b/crates/engine/tests/plugin_safe_mode.rs @@ -198,6 +198,25 @@ fn installing_a_disabled_plug_in_again_turns_it_on() { let _ = std::fs::remove_dir_all(&dir); } +/// When the grant of an update can't be stored, the store keeps the old module, and it stays disabled. +#[test] +fn a_disabled_plug_in_whose_new_grant_cant_be_stored_stays_disabled() { + let _f = fresh(); + let dir = tmp_dir("reinstall-no-grant"); + let mut s = launch(&dir); + let manifest = r#"{"id":"test.sm.g","name":"Test","version":"1","kind":"filter","permissions":["doc.read"]}"#; + let module = |out: &str| vectorcraft_format::base64_encode(&wat::parse_str(tpl::returning(manifest, out)).unwrap()); + s.execute("plugin.install", &json!({"dataBase64": module(r#"{"objects":[]}"#), "grant": ["doc.read"]})).unwrap(); + s.execute("plugin.setEnabled", &json!({"id": "test.sm.g", "enabled": false})).unwrap(); + let grants = dir.join("grants.json"); + std::fs::remove_file(&grants).unwrap(); + std::fs::create_dir_all(grants.join("block")).unwrap(); + s.execute("plugin.install", &json!({"dataBase64": module(r#"{"objects":[],"error":null}"#), "grant": ["doc.read"], "replace": true})).unwrap(); + assert_eq!(safe_mode::disabled().len(), 1, "the module the update could not replace stays disabled"); + safe_mode::set_disabled(vec![]); + let _ = std::fs::remove_dir_all(&dir); +} + /// A launch where the stored plug-in `id` stopped while loading: it is installed, then disabled by /// its stored file alone (as Disable and Continue saves it), and the next launch skips it. fn stored_plug_in_disabled_by_file(dir: &std::path::Path, id: &str) -> Session { diff --git a/crates/eps/Cargo.toml b/crates/eps/Cargo.toml index eac7b16b2..fb84265b2 100644 --- a/crates/eps/Cargo.toml +++ b/crates/eps/Cargo.toml @@ -17,6 +17,8 @@ vectorcraft-effects = { workspace = true } vectorcraft-pathops = { workspace = true } vectorcraft-brush = { workspace = true } vectorcraft-text = { workspace = true } +# The glyph outlines of Type 42 fonts a composite font shows. +skrifa = { workspace = true } # Windows metafile previews, opened when a file's PostScript can't be read. vectorcraft-metafile = { workspace = true } # Non-native art in Illustrator editing data (a placed PDF's content, kept as a PDF). diff --git a/crates/eps/src/import.rs b/crates/eps/src/import.rs index 8bc8a174c..8e3a53ba2 100644 --- a/crates/eps/src/import.rs +++ b/crates/eps/src/import.rs @@ -44,10 +44,13 @@ mod obj; mod preview; mod shading; mod text; +mod type42; #[cfg(test)] mod tests; #[cfg(test)] +mod tests_composite; +#[cfg(test)] mod tests_fontnames; #[cfg(test)] mod tests_generators; diff --git a/crates/eps/src/import/ai.rs b/crates/eps/src/import/ai.rs index b0359e172..c29301583 100644 --- a/crates/eps/src/import/ai.rs +++ b/crates/eps/src/import/ai.rs @@ -40,11 +40,13 @@ pub(crate) use envelope::MAX_CHECKED; pub(crate) use envelope::{WARPED_TYPE_DRAWN, WarpedType}; pub(crate) use frames::text_frames; pub(crate) use paint::MAX_STOPS as MAX_GRADIENT_STOPS; +#[cfg(test)] +pub(crate) use read::with_image_limits; pub use read::{ - BindingRef, Brushes, IMAGE_TOO_LARGE, PaletteSwatches, PlacedPage, Structure, active_artboard, artboards, bindings_note, brushes, graphic_styles, - palette, read, read_beside, variables, + BindingRef, Brushes, PaletteSwatches, PlacedPage, Structure, active_artboard, artboards, bindings_note, brushes, graphic_styles, palette, read, + read_beside, variables, }; -pub(crate) use read::{MAX_SYMBOLS, join_and, read_art_style, slot_frame, slot_of, symbol_art_member}; +pub(crate) use read::{MAX_SYMBOLS, join_and, read_art_style, read_over_page, slot_frame, slot_of, symbol_art_member}; pub(crate) use read::is_look_slot; diff --git a/crates/eps/src/import/ai/paint.rs b/crates/eps/src/import/ai/paint.rs index 178238dc1..8c863ba99 100644 --- a/crates/eps/src/import/ai/paint.rs +++ b/crates/eps/src/import/ai/paint.rs @@ -67,20 +67,29 @@ pub(super) fn color_op(op: &str, vals: &[V]) -> Option<(Paint, Option)> { "x" => named(vals, Some(false)), "xx" => named(vals, None), "xk" => named(vals, None).map(|(p, n)| (p, n.map(|n| Named { spot: false, ..n }))), - "xs" | "xz" => registration(vals), + "xs" | "xz" => registration(op, vals), _ => None, } } +fn is_registration(name: &str) -> bool { + matches!(name, vectorcraft_color::swatch::REGISTRATION | "Registration" | "/All" | "All" | "/[Registration]") +} + /// `c m y k (name) tint x` or `c m y k [r g b | L a b] (name) tint type Xx` (type 1: RGB, 2: Lab); /// `rgb` forces RGB or CMYK. fn named(vals: &[V], rgb: Option) -> Option<(Paint, Option)> { - let at = vals.iter().rposition(|v| matches!(v, V::Str(_)))?; + let at = vals.iter().rposition(|v| matches!(v, V::Str(_) | V::Name(_)))?; let name = vals.get(at)?.text()?; let before: Vec = vals.get(..at)?.iter().filter_map(V::num).map(|v| v as f32).collect(); let after: Vec = vals.get(at + 1..)?.iter().filter_map(V::num).map(|v| v as f32).collect(); let unit = |v: f32| v.clamp(0.0, 1.0); let tint = 1.0 - unit(*after.first()?); + if is_registration(&name) { + let full = Color::cmyk(1.0, 1.0, 1.0, 1.0); + let paint = Paint::Solid { color: full.tinted(tint), swatch: Some(vectorcraft_color::swatch::REGISTRATION.into()), tint }; + return Some((paint, None)); + } let n = before.len(); let kind = after.get(1).copied(); let color = if rgb.unwrap_or(kind == Some(1.0)) { @@ -102,9 +111,20 @@ fn named(vals: &[V], rgb: Option) -> Option<(Paint, Option)> { /// `… (name) tint Xs` / `… (name) tint type Xz`: the built-in Registration swatch at the tint (its /// own colour, whatever values the operands give). -fn registration(vals: &[V]) -> Option<(Paint, Option)> { - let at = vals.iter().rposition(|v| matches!(v, V::Str(_)))?; - let tint = 1.0 - vals.get(at + 1).and_then(V::num).unwrap_or(0.0).clamp(0.0, 1.0) as f32; +fn registration(op: &str, vals: &[V]) -> Option<(Paint, Option)> { + let at = vals.iter().rposition(|v| matches!(v, V::Str(_) | V::Name(_))); + let tint = if let Some(i) = at { + 1.0 - vals.get(i + 1).and_then(V::num).unwrap_or(0.0).clamp(0.0, 1.0) as f32 + } else { + let mut nums = vals.iter().rev().filter_map(V::num); + let t = if op.eq_ignore_ascii_case("xz") { + let _type = nums.next(); + nums.next().unwrap_or(0.0) + } else { + nums.next().unwrap_or(0.0) + }; + 1.0 - t.clamp(0.0, 1.0) as f32 + }; let full = Color::cmyk(1.0, 1.0, 1.0, 1.0); Some((Paint::Solid { color: full.tinted(tint), swatch: Some(vectorcraft_color::swatch::REGISTRATION.into()), tint }, None)) } @@ -512,6 +532,29 @@ mod tests { let mut v = nums(&[1.0, 1.0, 1.0, 1.0]); v.extend([V::Str(b"[Registration]".to_vec()), V::Num(0.0)]); assert!(matches!(color_op("Xs", &v), Some((Paint::Solid { tint, .. }, None)) if tint == 1.0)); + let mut v = nums(&[1.0, 1.0, 1.0, 1.0]); + v.extend([V::Str(b"[Registration]".to_vec()), V::Num(1.0)]); + assert!( + matches!(color_op("Xs", &v), Some((Paint::Solid { tint, color, .. }, None)) if tint == 0.0 && color == Color::cmyk(1.0, 1.0, 1.0, 1.0).tinted(0.0)) + ); + let mut v = nums(&[1.0, 1.0, 1.0, 1.0]); + v.extend([V::Name("All".into()), V::Num(1.0)]); + assert!(matches!(color_op("Xs", &v), Some((Paint::Solid { tint, .. }, None)) if tint == 0.0)); + let mut v = nums(&[1.0, 1.0, 1.0, 1.0]); + v.extend([V::Str(b"[Registration]".to_vec()), V::Num(0.2), V::Num(0.0)]); + let (p, named) = color_op("Xx", &v).unwrap(); + assert!(named.is_none()); + assert!(matches!(p, Paint::Solid { swatch: Some(s), tint, .. } if s == "[Registration]" && (tint - 0.8).abs() < 1e-6)); + let mut v = nums(&[1.0, 1.0, 1.0, 1.0]); + v.extend([V::Name("All".into()), V::Num(0.5), V::Num(0.0)]); + let (p, named) = color_op("Xx", &v).unwrap(); + assert!(named.is_none()); + assert!(matches!(p, Paint::Solid { swatch: Some(s), tint, .. } if s == "[Registration]" && (tint - 0.5).abs() < 1e-6)); + // Name-free Xs and Xz operands + let v = nums(&[1.0, 1.0, 1.0, 1.0, 0.3]); + assert!(matches!(color_op("Xs", &v), Some((Paint::Solid { tint, .. }, None)) if (tint - 0.7).abs() < 1e-6)); + let v = nums(&[1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.2, 1.0]); + assert!(matches!(color_op("Xz", &v), Some((Paint::Solid { tint, .. }, None)) if (tint - 0.8).abs() < 1e-6)); } #[test] @@ -528,6 +571,14 @@ mod tests { assert_eq!(named.name, "MySpot"); assert!((named.tint - 0.6).abs() < 1e-6); assert!(matches!(p, Paint::Solid { swatch: Some(_), .. })); + let mut v_name = nums(&[0.0, 0.5, 1.0, 0.0]); + v_name.push(V::Name("MySpot".into())); + v_name.push(V::Num(0.4)); + let (p, named) = color_op("x", &v_name).unwrap(); + let named = named.unwrap(); + assert_eq!(named.name, "MySpot"); + assert!((named.tint - 0.6).abs() < 1e-6); + assert!(matches!(p, Paint::Solid { swatch: Some(_), .. })); assert!(color_op("k", &nums(&[1.0])).is_none()); // A global process colour: CMYK (type 0) or RGB (type 1), not an ink. let mut v = nums(&[0.0, 0.0, 0.0, 1.0]); diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index f49c598dc..b98a79abd 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -34,7 +34,9 @@ //! - Images: `%AI5_BeginRaster`, the colour space `XN`, `[matrix] bounds w h bits type alpha …` //! and the samples after `XI`. 8-bit CMYK samples keep their ink amounts in a CMYK TIFF, and //! an alpha channel that hides part of such an image becomes its opacity mask; other samples -//! become an RGBA PNG. +//! become an RGBA PNG. Images larger than 2^25 pixels keep their samples' channels (1-bit gray as +//! a 1-bit PNG), and are averaged down past a target size ([`raster`]); past the hard limits, one +//! is a box that the page's art replaces when there is a page ([`read_over_page`]). //! - Linked images in the version 7 form (§8.2, §8.3): `(path) modified XG`, then `XF` with the //! operands `XI` takes and no samples. Each is a linked image of that file showing a placeholder //! over the image's place until its file is found. An `XF` without a file name before it, or @@ -134,6 +136,7 @@ mod document; mod masks; mod on_path; mod ops; +mod raster; mod types; pub(crate) use art::join_and; @@ -143,10 +146,12 @@ pub use document::{ }; pub(crate) use document::{slot_frame, slot_of}; pub(crate) use ops::read_art_style; +#[cfg(test)] +pub(crate) use raster::with_image_limits; +pub use types::PlacedPage; use types::{BLEND_BUDGET, BrushArtColors, FLIP, Frame, GState, Graph, Kind, PatternHead, PendingEnvelope, Place, PlacedTail, Raster, SymbolHead}; #[cfg(test)] pub use types::{GRAPH_PLAIN, GRAPH_UNENDED, LINKED_PLACEHOLDERS, NON_NATIVE_ART, NON_NATIVE_ART_UNREAD, VERSIONED_UNENDED}; -pub use types::{IMAGE_TOO_LARGE, PlacedPage}; pub(super) use types::{blend_mode, blend_name}; #[path = "legacy_text.rs"] @@ -235,19 +240,32 @@ pub(super) fn remember_xml_id(xml_ids: &mut HashMap>, byt /// when `large_canvas` is set: an EPS's page is drawn at the full size, but a `.ai`'s PDF part may /// keep the reduced one, which the PDF import reads as it is (#2031). pub fn read(data: &[u8], large_canvas: bool) -> Result { + read_with(data, large_canvas, false) +} + +/// [`read`] for a file whose page art replaces the objects the reader doesn't read (see +/// [`Reader::unread_box`]): an image past the hard limits ([`raster::Limits`]) is then such an +/// object, where [`read`] leaves it out with a note. +pub(crate) fn read_over_page(data: &[u8], large_canvas: bool) -> Result { + read_with(data, large_canvas, true) +} + +fn read_with(data: &[u8], large_canvas: bool, page_art: bool) -> Result { let mut r = Reader::with_names(data); r.large_canvas = large_canvas.then_some(1.0); + r.page_art = page_art; r.run()?; r.finish() } /// [`read`] for editing data kept apart from the program `ps` it came with (an EPS's private /// data): the program's header gives the author and the custom colors the editing data's header -/// doesn't. -pub fn read_beside(data: &[u8], large_canvas: bool, ps: &[u8]) -> Result { +/// doesn't. `page_art`: as [`read_over_page`] reads a file. +pub fn read_beside(data: &[u8], large_canvas: bool, ps: &[u8], page_art: bool) -> Result { let header = Reader::header_of(ps); let mut r = Reader::with_names(data); r.large_canvas = large_canvas.then_some(1.0); + r.page_art = page_art; // The program's custom colors have swatches of their names, which no gradient or pattern takes. for n in &header.custom_colors { r.global_names.reserve(&n.name); @@ -587,6 +605,8 @@ struct Reader<'a> { /// (see [`Reader::ensure_space`]); 1 (no scaling) when the file doesn't declare one, `None` /// when the declared factor is not applied ([`read`]). large_canvas: Option, + /// The file has page art to replace the objects that aren't read ([`read_over_page`]). + page_art: bool, /// The declared `%AI24_LargeCanvasScale` (1 without one), applied or not. canvas_scale: f64, /// `%AI5_RulerUnits` from the header (Illustrator 7 spec table 3, plus centimeters and pixels). @@ -756,6 +776,7 @@ impl<'a> Reader<'a> { art_size: None, template_center: None, large_canvas: None, + page_art: false, canvas_scale: 1.0, ruler_units: None, pattern: None, diff --git a/crates/eps/src/import/ai/read/art.rs b/crates/eps/src/import/ai/read/art.rs index 7f0b75823..7d2565172 100644 --- a/crates/eps/src/import/ai/read/art.rs +++ b/crates/eps/src/import/ai/read/art.rs @@ -1,8 +1,9 @@ use super::containers::close; use super::document::TEXT_SLOT; +use super::raster::{self, Limits, Shape}; use super::types::{ - FLIP, Frame, IMAGE_TOO_LARGE, Kind, LINKED_PLACEHOLDERS, LINKED_PREVIEWS, MAX_PIXELS, MAX_PLACED_ELSEWHERE, MAX_POINTS, MAX_SIDE, NON_NATIVE_ART, - NON_NATIVE_ART_UNREAD, PLACED_CROP, PLACED_PAGE, Place, PlacedPage, PlacedTail, + FLIP, Frame, IMAGE_TOO_LARGE, Kind, LARGE_IMAGES, LINKED_PLACEHOLDERS, LINKED_PREVIEWS, MAX_PIXELS, MAX_PLACED_ELSEWHERE, MAX_POINTS, MAX_SIDE, + NON_NATIVE_ART, NON_NATIVE_ART_UNREAD, PLACED_CROP, PLACED_PAGE, Place, PlacedPage, PlacedTail, }; use super::{ Affine, AnchorKind, Appearance, AppearanceItem, Arc, BezPath, CANVAS_CENTRE, FillLayer, ImageBlob, ImageObject, Inks, Instance, LinkInfo, Node, @@ -377,7 +378,7 @@ impl<'a> Reader<'a> { Some(before) if !vals.iter().any(|v| matches!(v, V::Arr(_))) => before, _ => vals, }; - self.image_from(vals, data) + self.image_from(vals, data, false) } /// The matrix and the numbers of an image's operands (`[a b c d tx ty] llx lly urx ury w h bits @@ -411,8 +412,22 @@ impl<'a> Reader<'a> { } } - /// An image of operands `vals` and samples `data`. - pub(super) fn image_from(&mut self, vals: Vec, data: &[u8]) -> Result<(), String> { + /// The bytes of samples (colour and alpha) that the image of `XI` operands `vals` reads: 0 when + /// it isn't read (operands missing, or past the hard limits). + pub(super) fn sample_bytes(&self, vals: &[V]) -> usize { + let space = self.raster.as_ref().and_then(|r| r.space.clone()).unwrap_or_default(); + let n = Self::nums(vals); + let [_, _, _, _, w, h, bits, kind, alpha, ..] = n.as_slice() else { return 0 }; + let shape = Shape { w: *w as u64, h: *h as u64, channels: channels(&space, *kind) as u64, bits: *bits as u64, alpha: *alpha > 0.0 }; + match (shape.step(&Limits::get()), shape.lens()) { + (Some(_), Some((colour, alpha))) => usize::try_from(colour + alpha).unwrap_or(0), + _ => 0, + } + } + + /// An image of operands `vals` and samples `data` (`decoded`: bytes already, whatever the + /// operands say). + pub(super) fn image_from(&mut self, vals: Vec, data: &[u8], decoded: bool) -> Result<(), String> { // A file `XG` named before this image isn't the file of a later `XF`. self.image_link = None; let space = self.raster.as_ref().and_then(|r| r.space.clone()).unwrap_or_default(); @@ -427,41 +442,42 @@ impl<'a> Reader<'a> { // ImageMask 1: the image is transparent and takes a color (version 7 specification §8.1). let image_mask = rest.first() == Some(&1.0); let (w, h) = (*w as u64, *h as u64); - if w.saturating_mul(h) > MAX_PIXELS { - self.image_left_out(IMAGE_TOO_LARGE); - return Ok(()); - } if w == 0 || h == 0 || ![1.0, 8.0].contains(bits) { self.image_left_out("an image VectorCraft can't read was left out"); return Ok(()); } - let channels: usize = match space.as_str() { - "DeviceGray" => 1, - "DeviceCMYK" => 4, - "DeviceRGB" => 3, - _ => match *kind as i64 { - 1 => 1, - 4 => 4, - _ => 3, - }, + let channels = channels(&space, *kind); + // The size is checked before anything is decoded or allocated. + let shape = Shape { w, h, channels: channels as u64, bits: *bits as u64, alpha: *alpha > 0.0 }; + let (Some(k), Some((need, alpha_len))) = (shape.step(&Limits::for_channels(channels)), shape.lens()) else { + return self.too_large_image(&m, w, h); }; - let decoded; - let data = if *binary == 0.0 { - let digits: Vec = data.iter().copied().filter(|b| b.is_ascii_hexdigit()).collect(); - decoded = crate::import::lex::hex_decode(&digits).unwrap_or_default(); - decoded.as_slice() + // Within the limits, these fit in memory (see `Limits::bytes`). + let (need, alpha_len) = (need as usize, alpha_len as usize); + let hex; + let data = if *binary == 0.0 && !decoded { + let (max, mut half) = (need + alpha_len, None); + let mut out = Vec::with_capacity(max.min(data.len() / 2)); + raster::push_hex(&mut out, &mut half, data, max); + raster::end_hex(&mut out, half, max); + hex = out; + hex.as_slice() } else { data }; - let row = if *bits == 1.0 { (w as usize * channels).div_ceil(8) } else { w as usize * channels }; - let need = row.saturating_mul(h as usize); + let row = shape.row().unwrap_or(0) as usize; // An alpha channel follows the colour samples, one byte a pixel. - let alpha_len = if *alpha > 0.0 { (w * h) as usize } else { 0 }; let (Some(samples), opacity) = (data.get(..need), data.get(need..need.saturating_add(alpha_len))) else { self.image_left_out("an image whose data is cut short was left out"); return Ok(()); }; let opacity = opacity.filter(|_| alpha_len > 0); + let gray1 = *bits == 1.0 && channels == 1 && opacity.is_none(); + if k > 1 || w * h > raster::SMALL || (gray1 && !image_mask) { + // An image mask keeps the color that colorizes it. + let colorize = (image_mask && channels == 1 && *bits == 1.0).then(|| self.mask_paint()); + return self.large_image(samples, opacity, shape, k, &m, colorize); + } // CMYK samples keep their ink amounts as a CMYK TIFF, as the PDF importer keeps a CMYK // image's, and an alpha channel that hides part of the image becomes its opacity mask. if channels == 4 @@ -595,6 +611,67 @@ impl<'a> Reader<'a> { self.add_image_colorized(im, None, colorize, hidden) } + /// An image of `s` past [`raster::SMALL`] pixels, or reduced by the step `k`, or 1-bit gray: + /// 1-bit gray at full size is a 1-bit PNG of its rows as they are; other images keep their + /// channels a byte each (gray or RGB in a PNG with the alpha channel, CMYK in a TIFF with the + /// alpha channel as its opacity mask), averaged over blocks of `k` × `k` pixels. The matrix + /// `m` places the image where its full size would be. + fn large_image(&mut self, samples: &[u8], opacity: Option<&[u8]>, s: Shape, k: u64, m: &[f64], colorize: Option) -> Result<(), String> { + let (ow, oh) = (s.w.div_ceil(k) as u32, s.h.div_ceil(k) as u32); + if k == 1 && s.bits == 1 && s.channels == 1 && opacity.is_none() { + let Some(png) = raster::gray1_png(samples, ow, oh, s.row().unwrap_or(0) as usize) else { + return self.too_large_image(m, s.w, s.h); + }; + return self.add_raster(ImageBlob::new("image/png", png), None, m, (ow, oh), colorize); + } + let cmyk = s.channels == 4; + let (pixels, mask) = raster::reduce(samples, opacity, s, k, !cmyk); + let blob = if cmyk { + Inks::new(ow, oh, pixels).and_then(|i| ImageBlob::cmyk_tiff(&i)) + } else { + use image::ImageEncoder as _; + let colour = match (s.channels, opacity.is_some()) { + (1, false) => image::ExtendedColorType::L8, + (1, true) => image::ExtendedColorType::La8, + (_, false) => image::ExtendedColorType::Rgb8, + (_, true) => image::ExtendedColorType::Rgba8, + }; + let mut png = vec![]; + image::codecs::png::PngEncoder::new(&mut png).write_image(&pixels, ow, oh, colour).ok().map(|()| ImageBlob::new("image/png", png)) + }; + // One that can't be encoded is left to the page's art, as one past the hard limits. + let Some(blob) = blob else { + return self.too_large_image(m, s.w, s.h); + }; + // Each pixel as read stands for a block of the full size's. + let (sx, sy) = (s.w as f64 / f64::from(ow), s.h as f64 / f64::from(oh)); + let m: Vec = match m { + [a, b, c, d, tx, ty] => vec![a * sx, b * sx, c * sy, d * sy, *tx, *ty], + other => other.to_vec(), + }; + if k > 1 { + self.warn(&format!("an embedded image of {} × {} pixels was reduced to {ow} × {oh} pixels to keep the layers", s.w, s.h)); + } + self.add_raster(blob, mask.as_deref(), &m, (ow, oh), colorize) + } + + /// An image of `w` × `h` pixels past the hard limits, which matrix `m` maps into art space: a + /// box for the page's art to replace ([`Reader::unread_box`]), or, without page art, left out + /// with a note. + fn too_large_image(&mut self, m: &[f64], w: u64, h: u64) -> Result<(), String> { + if !self.page_art { + self.image_left_out(IMAGE_TOO_LARGE); + return Ok(()); + } + let [a, b, c, d, tx, ty] = m else { + self.image_left_out("an image VectorCraft can't read was left out"); + return Ok(()); + }; + let xf = self.space() * Affine::new([*a, *b, *c, *d, *tx, *ty]) * FLIP; + let bounds = xf.transform_rect_bbox(Rect::new(0.0, 0.0, w as f64, h as f64)); + self.unread_box(LARGE_IMAGES, bounds, false).map(|_| ()) + } + /// Add the image `blob` (`w` × `h` pixels), which the matrix `m` maps into art space, with /// `alpha` (its alpha channel, one byte a pixel) as its opacity mask when it hides part of the /// image, and `colorize` as the color that colorizes it. @@ -978,6 +1055,20 @@ fn cmyk_rgb([c, m, y, k]: [u8; 4]) -> [u8; 3] { [rgb[0], rgb[1], rgb[2]] } +/// The colour channels of an image in the colour space `space` (`XN`), else of image type `kind`. +fn channels(space: &str, kind: f64) -> usize { + match space { + "DeviceGray" => 1, + "DeviceCMYK" => 4, + "DeviceRGB" => 3, + _ => match kind as i64 { + 1 => 1, + 4 => 4, + _ => 3, + }, + } +} + /// `a`, `a and b`, `a, b and c`. pub(crate) fn join_and(items: &[&str]) -> String { match items { diff --git a/crates/eps/src/import/ai/read/directives.rs b/crates/eps/src/import/ai/read/directives.rs index dfc55aa7a..68f981751 100644 --- a/crates/eps/src/import/ai/read/directives.rs +++ b/crates/eps/src/import/ai/read/directives.rs @@ -3,8 +3,9 @@ use std::collections::HashMap; use super::super::brush; use super::art::{four, sane, two}; use super::ops::Dictionaries; +use super::raster::{end_hex, push_hex}; use super::types::{ - FLIP, GState, Kind, MAX_DEPTH, MAX_HEX, MAX_LISTED, MAX_NODES, MAX_NOTE, MAX_PATTERNS, MAX_PLACED_FOLDER, MAX_STACK, MAX_SUBREADERS, MAX_SYMBOLS, + FLIP, GState, Kind, MAX_DEPTH, MAX_LISTED, MAX_NODES, MAX_NOTE, MAX_PATTERNS, MAX_PLACED_FOLDER, MAX_STACK, MAX_SUBREADERS, MAX_SYMBOLS, PatternHead, Place, Raster, SKIPPED, SymbolHead, VERSIONED_UNENDED, }; use super::{Arc, BrushArtColors, Global, Lexer, Node, NodeKind, PatternDef, Reader, Rect, Tok, V, marker, obj}; @@ -52,9 +53,7 @@ impl<'a> Reader<'a> { && !line.is_empty() && line.bytes().all(|b| b.is_ascii_hexdigit()) { - if r.hex.len() < MAX_HEX { - r.hex.extend_from_slice(line.as_bytes()); - } + push_hex(&mut r.samples, &mut r.half, line.as_bytes(), r.max); return Ok(()); } // The header's comments (AI7 specification §2.1.1; DSC 3.0). @@ -182,8 +181,9 @@ impl<'a> Reader<'a> { } else if line.starts_with("AI5_EndRaster") { // A file `XG` named in this raster object is no later one's. self.image_link = None; - if let Some(Raster { operands: Some(vals), hex, .. }) = self.raster.take() { - return self.image_from(vals, &hex); + if let Some(Raster { operands: Some(vals), mut samples, half, max, .. }) = self.raster.take() { + end_hex(&mut samples, half, max); + return self.image_from(vals, &samples, true); } } else if line.starts_with("AI5_BeginPlace") { // One that is still open (no `~` ended it) isn't read. diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 3d95c9485..4861e342d 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -43,12 +43,26 @@ impl<'a> Reader<'a> { } } + /// `X=` in a `/Mask` dictionary: the opacity mask's art starts, read until its `X+` + /// ([`Self::end_mask`]). fn open_mask_object(&mut self, hidden: bool) -> Result<(), String> { if !matches!(self.frames.last(), Some(Frame { kind: Kind::Obj(o), .. }) if o.ty == "Mask") { // `X=` also introduces legacy text frame art, read by `text_frames`. return Ok(()); } self.end_path_quietly(); + // The art goes on from the paint before the dictionaries it is in, which `begin_obj` set + // aside: the writer leaves out paint that doesn't change, so a mask rectangle with no color + // of its own takes the white of the art before it, not a fresh state's black, which hides + // what it masks. Lock, visibility and transparency start afresh, as a dictionary's art does. + if let Some(f) = self.frames.iter().rev().take_while(|f| matches!(f.kind, Kind::Obj(_))).last() { + let before = &f.gs; + let gs = &mut self.gs; + (gs.fill, gs.stroke, gs.dash) = (before.fill.clone(), before.stroke.clone(), before.dash.clone()); + (gs.width, gs.cap, gs.join, gs.miter, gs.rule) = (before.width, before.cap, before.join, before.miter, before.rule); + (gs.overprint_fill, gs.overprint_stroke) = (before.overprint_fill, before.overprint_stroke); + (self.pending_unread_fill, self.pending_unread_stroke) = (f.pending_unread_fill, f.pending_unread_stroke); + } self.open(Kind::Mask, hidden) } @@ -376,8 +390,10 @@ impl<'a> Reader<'a> { "TZ" => self.tz(&vals), // An image whose samples follow in comment lines (read at `%AI5_EndRaster`). "XI" if self.raster.is_some() => { + let max = self.sample_bytes(&vals); if let Some(r) = self.raster.as_mut() { r.operands = Some(vals); + r.max = max; } } // A linked image (§8.2, §8.3): `(path) modified XG` names the file of the `XF` image after @@ -509,7 +525,7 @@ impl<'a> Reader<'a> { fn color(&mut self, w: &str, vals: &[V]) { let Some((p, named)) = paint::color_op(w, vals) else { return }; // Registration (`Xs`, `Xz`) says nothing of the document's colour model. - let registration = matches!(w, "Xs" | "XS" | "Xz" | "XZ"); + let registration = matches!(w, "Xs" | "XS" | "Xz" | "XZ") || p.is_registration(); match p.color().filter(|_| !registration) { Some(vectorcraft_color::Color::Rgb { .. }) => self.rgb_colors += 1, Some(vectorcraft_color::Color::Cmyk { .. }) => self.cmyk_colors += 1, @@ -1189,9 +1205,9 @@ pub(crate) fn read_art_style(section: &[u8], name: &str, own_gradient: Option<&P /// A style's stroke as an appearance item (`None` for a fill). fn stroke_layer(item: &crate::import::ai::style::StyleItem) -> Option { - let crate::import::ai::style::StyleItem::Stroke { paint, width, cap, join, miter, dash, profile, brush } = item else { return None }; + let crate::import::ai::style::StyleItem::Stroke { paint, width, cap, join, miter, dash, profile, align, brush } = item else { return None }; let mut layer = StrokeLayer::new(paint.clone(), line_width(*width)); - (layer.cap, layer.join, layer.miter_limit, layer.dash, layer.profile) = (*cap, *join, *miter, dash.clone(), profile.clone()); + (layer.cap, layer.join, layer.miter_limit, layer.dash, layer.profile, layer.align) = (*cap, *join, *miter, dash.clone(), profile.clone(), *align); layer.brush = brush.as_ref().map(|(name, _)| name.clone()); Some(AppearanceItem::Stroke(layer)) } diff --git a/crates/eps/src/import/ai/read/raster.rs b/crates/eps/src/import/ai/read/raster.rs new file mode 100644 index 000000000..da88fd144 --- /dev/null +++ b/crates/eps/src/import/ai/read/raster.rs @@ -0,0 +1,268 @@ +//! The size of an embedded image (`XI`), checked before its samples are decoded: read at full size +//! up to a target number of pixels, averaged down in whole steps past it, and not read at all past +//! the hard limits ([`Limits`]). + +use std::io::Write as _; + +/// Images up to this many pixels read as they always have: an RGBA PNG, or a CMYK TIFF. +pub(super) const SMALL: u64 = 1 << 25; +/// Most bytes of one image's samples, its alpha channel included. +pub(super) const MAX_BYTES: u64 = 512 << 20; +/// Most pixels of one image. +pub(super) const MAX_PIXELS: u64 = 1 << 31; +/// Longest side of an image as it comes in (pixels). +const MAX_SIDE: u64 = 65_535; + +/// How large an image is read. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) struct Limits { + /// Most pixels of an image as it comes in; larger ones are reduced. + pub(super) target: u64, + /// Most pixels of an image read at all. + pub(super) pixels: u64, + /// Most bytes of an image's samples, its alpha channel included. + pub(super) bytes: u64, +} + +#[cfg(test)] +thread_local! { + static TEST_LIMITS: std::cell::Cell> = const { std::cell::Cell::new(None) }; +} + +impl Limits { + /// The limits on this machine: [`target`] pixels, [`MAX_PIXELS`] and [`MAX_BYTES`]. + pub(super) fn get() -> Self { + #[cfg(test)] + if let Some(l) = TEST_LIMITS.with(std::cell::Cell::get) { + return l; + } + static TARGET: std::sync::OnceLock = std::sync::OnceLock::new(); + let target = *TARGET.get_or_init(|| target(vectorcraft_render::installed_memory())); + Self { target, pixels: MAX_PIXELS, bytes: MAX_BYTES } + } + + /// The limits for an image of `channels` colour channels: a CMYK image comes in at most as + /// large as a CMYK TIFF holds ([`vectorcraft_doc::cmyk::MAX_CMYK_PIXELS`]). + pub(super) fn for_channels(channels: usize) -> Self { + let mut l = Self::get(); + if channels == 4 { + l.target = l.target.min(vectorcraft_doc::cmyk::MAX_CMYK_PIXELS); + } + l + } +} + +/// `f` run with images read under `target` pixels, `pixels` and `bytes` in place of this machine's +/// limits. +#[cfg(test)] +pub(crate) fn with_image_limits(target: u64, pixels: u64, bytes: u64, f: impl FnOnce() -> T) -> T { + TEST_LIMITS.with(|l| l.set(Some(Limits { target, pixels, bytes }))); + let out = f(); + TEST_LIMITS.with(|l| l.set(None)); + out +} + +/// Most pixels of an image as it comes in, on a machine with `memory` bytes installed (`None`: not +/// known): a thirty-second of it, from 2^26 to 2^28 pixels (an RGBA decode of at most 1 GiB); on the +/// web, a quarter of the largest image the renderer decodes, at most 2^27. +fn target(memory: Option) -> u64 { + if cfg!(target_arch = "wasm32") { + (1u64 << 27).min(vectorcraft_doc::MAX_RASTER_ALLOC / 4) + } else { + memory.map_or(1 << 26, |m| (m / 32).clamp(1 << 26, 1 << 28)) + } +} + +/// An image's layout from its `XI` operands. +#[derive(Clone, Copy, Debug)] +pub(super) struct Shape { + pub(super) w: u64, + pub(super) h: u64, + /// Colour channels a pixel: 1, 3 or 4. + pub(super) channels: u64, + /// Bits a channel: 1 or 8. + pub(super) bits: u64, + /// An alpha channel, a byte a pixel, follows the colour samples. + pub(super) alpha: bool, +} + +impl Shape { + /// Bytes in a row of colour samples (a 1-bit row is padded to a whole byte). + pub(super) fn row(&self) -> Option { + Some(self.w.checked_mul(self.channels)?.checked_mul(self.bits)?.div_ceil(8)) + } + + /// Bytes of colour samples, and of the alpha channel. + pub(super) fn lens(&self) -> Option<(u64, u64)> { + let colour = self.row()?.checked_mul(self.h)?; + let alpha = if self.alpha { self.w.checked_mul(self.h)? } else { 0 }; + colour.checked_add(alpha)?; + Some((colour, alpha)) + } + + /// The step `k` the image is reduced by (1: full size): the smallest that brings it within + /// `limits.target` pixels and [`MAX_SIDE`] a side. `None` past `limits.pixels` or + /// `limits.bytes`. + pub(super) fn step(&self, limits: &Limits) -> Option { + let pixels = self.w.checked_mul(self.h)?; + let (colour, alpha) = self.lens()?; + if pixels > limits.pixels || colour + alpha > limits.bytes { + return None; + } + let fits = |k: u64| { + let (w, h) = (self.w.div_ceil(k), self.h.div_ceil(k)); + w <= MAX_SIDE && h <= MAX_SIDE && w.saturating_mul(h) <= limits.target.max(1) + }; + // No step below these brings it within the limits. + let mut k = ((pixels as f64 / limits.target.max(1) as f64).sqrt().floor() as u64) + .max(self.w.div_ceil(MAX_SIDE)) + .max(self.h.div_ceil(MAX_SIDE)) + .max(1); + while !fits(k) { + k += 1; + } + Some(k) + } +} + +/// The samples of the image `s` (rows of [`Shape::row`] bytes; a set bit is the channel at full +/// strength) and its `alpha` channel averaged over blocks of `k` × `k` pixels into a byte a channel, +/// each block at the right and bottom edges over the pixels it has. With `inline`, the alpha follows +/// each pixel's channels; otherwise it comes back on its own. +pub(super) fn reduce(samples: &[u8], alpha: Option<&[u8]>, s: Shape, k: u64, inline: bool) -> (Vec, Option>) { + let (w, h, k) = (s.w as usize, s.h as usize, k.max(1) as usize); + let (ch, row) = (s.channels as usize, s.row().unwrap_or(0) as usize); + let (ow, oh) = (w.div_ceil(k), h.div_ceil(k)); + let n = ch + usize::from(alpha.is_some()); + let mut out = Vec::with_capacity(ow * oh * if inline { n } else { ch }); + let mut alpha_out = (alpha.is_some() && !inline).then(|| Vec::with_capacity(ow * oh)); + let mut sums = vec![0u64; ow * n]; + for oy in 0..oh { + sums.fill(0); + let rows = oy * k..((oy + 1) * k).min(h); + let tall = rows.len() as u64; + for y in rows { + let line = samples.get(y * row..(y + 1) * row).unwrap_or_default(); + let opacity = alpha.and_then(|a| a.get(y * w..(y + 1) * w)); + for x in 0..w { + let at = x / k * n; + for c in 0..ch { + let i = x * ch + c; + let v = + if s.bits == 1 { line.get(i / 8).map_or(0, |b| ((b >> (7 - i % 8)) & 1) * 255) } else { line.get(i).copied().unwrap_or(0) }; + sums[at + c] += u64::from(v); + } + if alpha.is_some() { + sums[at + ch] += u64::from(opacity.and_then(|o| o.get(x)).copied().unwrap_or(255)); + } + } + } + for ox in 0..ow { + let count = ((((ox + 1) * k).min(w) - ox * k) as u64 * tall).max(1); + let avg = |sum: u64| ((sum + count / 2) / count) as u8; + out.extend(sums[ox * n..ox * n + ch].iter().map(|v| avg(*v))); + if alpha.is_some() { + let a = avg(sums[ox * n + ch]); + match alpha_out.as_mut() { + Some(o) => o.push(a), + None => out.push(a), + } + } + } + } + (out, alpha_out) +} + +/// A grayscale PNG of 1-bit rows as they are (`row` bytes each, a set bit white): `w` × `h` +/// pixels without expanding them. +pub(super) fn gray1_png(samples: &[u8], w: u32, h: u32, row: usize) -> Option> { + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + for y in 0..h as usize { + // Filter type 0 (none), then the row. + z.write_all(&[0]).ok()?; + z.write_all(samples.get(y * row..(y + 1) * row)?).ok()?; + } + let data = z.finish().ok()?; + let mut png = b"\x89PNG\r\n\x1a\n".to_vec(); + // Bit depth 1, colour type 0 (gray), deflate, filter method 0, no interlacing. + let ihdr = [&w.to_be_bytes()[..], &h.to_be_bytes(), &[1, 0, 0, 0, 0]].concat(); + for (ty, body) in [(b"IHDR", ihdr.as_slice()), (b"IDAT", data.as_slice()), (b"IEND", &[])] { + png.extend_from_slice(&u32::try_from(body.len()).ok()?.to_be_bytes()); + png.extend_from_slice(ty); + png.extend_from_slice(body); + let mut crc = flate2::Crc::new(); + crc.update(ty); + crc.update(body); + png.extend_from_slice(&crc.sum().to_be_bytes()); + } + Some(png) +} + +/// Decode the hex digits in `text` onto `out`, `half` holding a digit that waits for its pair, up +/// to `max` bytes; other characters are skipped. +pub(super) fn push_hex(out: &mut Vec, half: &mut Option, text: &[u8], max: usize) { + for &b in text { + if out.len() >= max { + return; + } + let Some(v) = char::from(b).to_digit(16) else { continue }; + match half.take() { + Some(high) => out.push(high << 4 | v as u8), + None => *half = Some(v as u8), + } + } +} + +/// The last digit of [`push_hex`]'s, without its pair, as the byte it starts. +pub(super) fn end_hex(out: &mut Vec, half: Option, max: usize) { + if let Some(high) = half + && out.len() < max + { + out.push(high << 4); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A CMYK image is reduced to the size a CMYK TIFF holds, whatever the target. + #[test] + fn cmyk_images_come_in_at_most_as_large_as_a_cmyk_tiff() { + let shape = |channels| Shape { w: 9000, h: 9000, channels, bits: 8, alpha: false }; + let (rgb, cmyk) = with_image_limits(1 << 28, MAX_PIXELS, MAX_BYTES * 4, || { + (shape(3).step(&Limits::for_channels(3)), shape(4).step(&Limits::for_channels(4))) + }); + assert_eq!(rgb, Some(1)); + let k = cmyk.unwrap(); + assert_eq!(k, 2); + assert!(9000u64.div_ceil(k).pow(2) <= vectorcraft_doc::cmyk::MAX_CMYK_PIXELS); + } + + /// The step keeps each side within 65,535 pixels, and sizes past the limits or overflowing + /// aren't read. + #[test] + fn steps_keep_sides_within_the_limit() { + let limits = Limits { target: 1 << 28, pixels: MAX_PIXELS, bytes: MAX_BYTES }; + let long = Shape { w: 200_000, h: 1, channels: 1, bits: 1, alpha: false }; + assert_eq!(long.step(&limits), Some(4)); + let huge = Shape { w: u64::MAX, h: 2, channels: 3, bits: 8, alpha: true }; + assert_eq!(huge.step(&limits), None); + let many = Shape { w: 1 << 16, h: 1 << 16, channels: 1, bits: 1, alpha: false }; + assert_eq!(many.step(&limits), None); + } + + /// Hex digits are decoded across calls, other characters skipped, up to the bytes asked for. + #[test] + fn hex_is_decoded_up_to_the_bytes_asked_for() { + let (mut out, mut half) = (vec![], None); + push_hex(&mut out, &mut half, b"F f0 1", 8); + push_hex(&mut out, &mut half, b"2a", 8); + end_hex(&mut out, half, 8); + assert_eq!(out, [0xff, 0x01, 0x2a]); + let (mut out, mut half) = (vec![], None); + push_hex(&mut out, &mut half, b"0102030", 2); + end_hex(&mut out, half, 2); + assert_eq!(out, [1, 2]); + } +} diff --git a/crates/eps/src/import/ai/read/types.rs b/crates/eps/src/import/ai/read/types.rs index 5fe93923a..5d34e248e 100644 --- a/crates/eps/src/import/ai/read/types.rs +++ b/crates/eps/src/import/ai/read/types.rs @@ -21,17 +21,18 @@ pub(super) const BLEND_BUDGET: usize = 50_000; /// Most points in one path. pub(super) const MAX_POINTS: usize = 4_000_000; -/// Largest image read (pixels). +/// Largest placed file's preview read (pixels). pub(super) const MAX_PIXELS: u64 = 1 << 25; /// Most placed files whose other placement ([`super::Structure::placed_elsewhere`]) is kept. pub(super) const MAX_PLACED_ELSEWHERE: usize = 4096; -/// The note for an image past [`MAX_PIXELS`]: the file then opens from its PDF part, which reads -/// larger images (#1479), unless that part can't be read either; then the image is left out. -pub const IMAGE_TOO_LARGE: &str = "an image too large to read from the editing data was left out"; +/// The note for an image past the hard limits ([`super::raster::Limits`]) in a file without page +/// art to replace it. +pub(super) const IMAGE_TOO_LARGE: &str = "an image too large to read from the editing data was left out"; -/// Most hex digits of an image's samples read from comment lines (CMYK and alpha at the largest size). -pub(super) const MAX_HEX: usize = (MAX_PIXELS as usize) * 10; +/// What an image past the hard limits is, as the notes name it: with page art, its box becomes +/// that art (#1479). +pub(super) const LARGE_IMAGES: &str = "very large images"; /// Largest side of the document and its artboards (points). pub(super) const MAX_SIDE: f64 = 1e6; @@ -281,8 +282,12 @@ pub(super) struct Raster { /// `XI`'s operands when its samples follow as hex in comment lines (`%FF00…`) up to /// `%AI5_EndRaster`, the form the version 7 specification documents (§8). pub(super) operands: Option>, - /// Those hex digits. - pub(super) hex: Vec, + /// The bytes of those hex digits, up to `max`. + pub(super) samples: Vec, + /// The bytes of samples the image reads (0 when it isn't read). + pub(super) max: usize, + /// A digit waiting for its pair. + pub(super) half: Option, } /// A placed file's operands, from `` ` `` and its preview, until `~`. diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index 04ddf27c1..857837afe 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -36,8 +36,15 @@ //! Tool)` or `(Adobe ArtOnPath Brush Tool)`, with its stroke's style and the brush's settings for //! the object as entries (see [`super::brush`]). //! +//! A stroke aligned inside or outside its path (Stroke panel › Align Stroke, +//! https://helpx.adobe.com/illustrator/desktop/draw-and-paint/use-strokes/stroke-panel-options.html) +//! is its own basic filter too, `(Adobe Stroke Offset)`, with its stroke's style and +//! `StrokeOffsetInside` (1: inside, 0: outside, as its `DisplayString` names it) as entries. +//! Without that entry, or with it as anything but a `/Bool` 0 or 1, which side it paints isn't +//! known and the style isn't plain (its objects keep their drawn look). +//! //! A style is *plain* when every filter in it is one read here: fills and strokes (in a colour; fills -//! with a pattern or gradient too; strokes with a Calligraphic or Art brush too), each with its own +//! with a pattern or gradient too; strokes with a Calligraphic or Art brush or aligned inside or outside too), each with its own //! transparency and effects, and the effects of //! [`effect`]. Such objects are read as themselves with an appearance of those fills, strokes and //! effects; others keep their drawn look. Effect › Pathfinder effects (`(Adobe Pathfinder)` in the @@ -47,12 +54,12 @@ use std::collections::HashMap; use serde_json::json; use vectorcraft_color::{BlendMode, Paint}; -use vectorcraft_doc::{Dash, Effect, Knockout, LineCap, LineJoin, WidthProfile}; +use vectorcraft_doc::{Dash, Effect, Knockout, LineCap, LineJoin, StrokeAlign, WidthProfile}; use vectorcraft_effects::is_pathfinder; use super::brush::PathBrush; use super::lex::{Lexer, Tok}; -use super::obj::V; +use super::obj::{self, V}; use super::paint; const GLOW: &str = "Adobe Outer Glow"; // brand-ok: the filter's name in the files @@ -78,6 +85,8 @@ const BAS_RELIEF: &str = "PSAdapter_plugin_BsRl"; const SHAPE_EFFECTS: &str = "Adobe Shape Effects"; // brand-ok: the filter's name in the files const PATHFINDER: &str = "Adobe Pathfinder"; // brand-ok: the filter's name in the files +/// A stroke aligned inside or outside its path (Stroke panel › Align Stroke). +const STROKE_OFFSET: &str = "Adobe Stroke Offset"; // brand-ok: the filter's name in the files const CALLIGRAPHIC: &str = "Adobe Calligraphic Brush Tool"; // brand-ok: the filter's and plug-in's name in the files const ART_BRUSH: &str = "Adobe ArtOnPath Brush Tool"; // brand-ok: the filter's and plug-in's name in the files @@ -211,6 +220,8 @@ pub(super) enum StyleItem { miter: f64, dash: Option, profile: Option, + /// Which side of the path it paints (centred, or inside or outside a closed path). + align: StrokeAlign, /// A brush: its name, and its settings for this object. brush: Option<(String, PathBrush)>, }, @@ -421,6 +432,7 @@ pub(super) fn art_styles(data: &[u8]) -> HashMap { b"FillStyle" => style_entry = Some(Kind::Fill), b"StrokeStyle" | b"BlendStyle" => style_entry = Some(Kind::Other), b"Name" if name.is_none() && filters.is_empty() => name = operands.iter().rev().find_map(V::text), + b"All" | b"[Registration]" | b"Registration" => operands.push(V::Name(obj::text(n))), b"CompoundFilter" => pending = Some(true), b"BasicFilter" => pending = Some(false), b"Part" => in_basic = false, @@ -594,7 +606,8 @@ fn block_data(lex: &mut Lexer, encoded: &[u8]) -> Option> { fn paint_op(f: &mut Filter, op: &str, vals: &[V]) { let last = vals.iter().rev().find_map(V::num); match op { - "Xa" | "k" | "g" | "x" | "Xk" | "XA" | "K" | "G" | "X" | "XK" => { + "Xa" | "k" | "g" | "x" | "Xx" | "Xk" | "Xs" | "Xz" | "XA" | "K" | "G" | "X" | "XX" | "XK" | "XS" | "XZ" | "xa" | "xx" | "xk" | "xs" + | "xz" => { let Some((p, _)) = paint::color_op(op, vals) else { return }; if op.ends_with(|c: char| c.is_ascii_lowercase()) { f.fill = Some(p); @@ -1211,29 +1224,49 @@ fn paint_item(f: &Filter) -> Result, ()> { (None, None, Some(p)) => Ok(Some(StyleItem::Fill(p.clone()))), (None, None, None) => Err(()), }, - STROKE if !f.paints.0 => Ok(None), - // Dashes aligned to corners and path ends draw as the look's pieces: a solid end at each end - // and the pattern, fitted, between them. - STROKE if f.params.get("BSDashAdj").is_some_and(|v| *v != 0.0) => Err(()), - STROKE => match &f.stroke { - Some(p) if !f.gradient && f.pattern.is_none() => Ok(Some(StyleItem::Stroke { - paint: p.clone(), - width: f.width, - cap: f.cap, - join: f.join, - miter: f.miter, - dash: f.dash.clone(), - profile: width_profile(&f.arrays), - brush: None, - })), - _ => Err(()), - }, + STROKE => stroke_item(f, StrokeAlign::Center), CALLIGRAPHIC => calligraphic_stroke(f).map(Some).ok_or(()), + STROKE_OFFSET => offset_stroke(f), ART_BRUSH => art_stroke(f).map(Some).ok_or(()), _ => Err(()), } } +/// A stroke filter `f`'s stroke, painting on side `align` of its path: `Ok(None)` when it paints +/// nothing, `Err` when it paints in a way this doesn't read. +fn stroke_item(f: &Filter, align: StrokeAlign) -> Result, ()> { + if !f.paints.0 { + return Ok(None); + } + // Dashes aligned to corners and path ends draw as the look's pieces: a solid end at each end + // and the pattern, fitted, between them. + if f.params.get("BSDashAdj").is_some_and(|v| *v != 0.0) { + return Err(()); + } + match &f.stroke { + Some(p) if !f.gradient && f.pattern.is_none() => Ok(Some(StyleItem::Stroke { + paint: p.clone(), + width: f.width, + cap: f.cap, + join: f.join, + miter: f.miter, + dash: f.dash.clone(), + profile: width_profile(&f.arrays), + align, + brush: None, + })), + _ => Err(()), + } +} + +/// A Stroke Offset filter `f`: a stroke whose own style is in its dictionary, aligned inside its +/// path when `StrokeOffsetInside` is 1 and outside when it is 0 (its `DisplayString` says which). +/// `Err` when that switch is missing or not a switch, or for a stroke [`paint_item`] doesn't read. +fn offset_stroke(f: &Filter) -> Result, ()> { + let inside = switch(f, "StrokeOffsetInside", Kind::Bool)?.ok_or(())?; + stroke_item(f, if inside { StrokeAlign::Inside } else { StrokeAlign::Outside }) +} + /// A Calligraphic brush's filter `f` as the stroke it paints: `None` when it isn't a stroke in a /// colour, names no Calligraphic brush, or its settings aren't all there as numbers. An angle /// relative to the path isn't read. @@ -1261,6 +1294,7 @@ fn calligraphic_stroke(f: &Filter) -> Option { miter: f.miter, dash: None, profile: None, + align: StrokeAlign::Center, brush: Some((name.clone(), PathBrush::Calligraphic(super::brush::calligraphic(values, variation, codes)))), }) } @@ -1339,6 +1373,7 @@ fn art_stroke(f: &Filter) -> Option { miter: f.miter, dash: None, profile: None, + align: StrokeAlign::Center, brush: Some((name.clone(), PathBrush::Art)), }) } @@ -1383,7 +1418,7 @@ impl Builder<'_> { let c = &filters[k]; match c.name.as_str() { _ if c.compound => {} - FILL | STROKE | CALLIGRAPHIC | ART_BRUSH => match paint_item(c) { + FILL | STROKE | STROKE_OFFSET | CALLIGRAPHIC | ART_BRUSH => match paint_item(c) { Ok(Some(StyleItem::Stroke { .. })) => under.strokes = true, Ok(Some(StyleItem::Pattern { .. })) => under.patterns = true, _ => {} @@ -1397,7 +1432,7 @@ impl Builder<'_> { let c = &filters[k]; match c.name.as_str() { _ if c.compound => self.plain = false, - FILL | STROKE | CALLIGRAPHIC | ART_BRUSH => { + FILL | STROKE | STROKE_OFFSET | CALLIGRAPHIC | ART_BRUSH => { paints += 1; look.visible &= c.visible; match paint_item(c) { @@ -1518,7 +1553,7 @@ fn style(filters: &[Filter]) -> ArtStyle { let f = &filters[id]; match f.name.as_str() { STACK if f.compound => b.stack(id, &mut st), - FILL | STROKE | CALLIGRAPHIC | ART_BRUSH | CHAIN => { + FILL | STROKE | STROKE_OFFSET | CALLIGRAPHIC | ART_BRUSH | CHAIN => { if let Some((item, look)) = b.item(id) { st.items.push(item); st.looks.push(look); diff --git a/crates/eps/src/import/ai/style/tests.rs b/crates/eps/src/import/ai/style/tests.rs index 0845ca0d9..ae79a345e 100644 --- a/crates/eps/src/import/ai/style/tests.rs +++ b/crates/eps/src/import/ai/style/tests.rs @@ -1,4 +1,5 @@ use super::*; +use vectorcraft_color::Color; #[test] fn a_variable_width_stroke_keeps_its_profile() { @@ -1393,3 +1394,52 @@ fn a_style_is_read_as_a_writer_checks_it() { let _ = read(junk, Some(&own)); } } + +#[test] +fn art_styles_read_registration_and_named_colors() { + let reg_0 = fill("1 1 1 1 ([Registration]) 1 Xs"); + let reg_100 = fill("1 1 1 1 ([Registration]) 0 Xs"); + let reg_name = fill("1 1 1 1 /All 1 Xs"); + let named_spot = fill("0 0.5 1 0 (Shemen) 0 0 Xx"); + let stroke_reg = basic( + "Stroke Style Filter", + "/StrokeStyle : 0 R\n1 1 1 1 ([Registration]) 1 XS\n0 1 0 0 0 Xy\n0 J 0 j 1 w 10 M []0 d\n0 XR\n1 0 Xd\n/Def ;\n (StrokeStyle) ,\n", + ); + let data = format!( + "/KnownStyle :\n(Reg0) /Name ,\n/ActiveStyle :\n{}\n\ + /KnownStyle :\n(Reg100) /Name ,\n/ActiveStyle :\n{}\n\ + /KnownStyle :\n(RegName) /Name ,\n/ActiveStyle :\n{}\n\ + /KnownStyle :\n(Shemen) /Name ,\n/ActiveStyle :\n{}\n\ + /KnownStyle :\n(StrokeReg) /Name ,\n/ActiveStyle :\n{}\n", + compound("Chain Style Filter", &[compound("Stack Style Filter", &[reg_0])]), + compound("Chain Style Filter", &[compound("Stack Style Filter", &[reg_100])]), + compound("Chain Style Filter", &[compound("Stack Style Filter", &[reg_name])]), + compound("Chain Style Filter", &[compound("Stack Style Filter", &[named_spot])]), + compound("Chain Style Filter", &[compound("Stack Style Filter", &[stroke_reg])]), + ); + let styles = art_styles(data.as_bytes()); + let st0 = &styles["Reg0"]; + assert!(st0.plain); + let [StyleItem::Fill(Paint::Solid { swatch, tint, color })] = st0.items.as_slice() else { panic!("{st0:?}") }; + assert_eq!((swatch.as_deref(), *tint, *color), (Some(vectorcraft_color::swatch::REGISTRATION), 0.0, Color::cmyk(1.0, 1.0, 1.0, 1.0).tinted(0.0))); + + let st100 = &styles["Reg100"]; + assert!(st100.plain); + let [StyleItem::Fill(Paint::Solid { swatch, tint, color })] = st100.items.as_slice() else { panic!("{st100:?}") }; + assert_eq!((swatch.as_deref(), *tint, *color), (Some(vectorcraft_color::swatch::REGISTRATION), 1.0, Color::cmyk(1.0, 1.0, 1.0, 1.0))); + + let st_name = &styles["RegName"]; + assert!(st_name.plain); + let [StyleItem::Fill(Paint::Solid { swatch, tint, .. })] = st_name.items.as_slice() else { panic!("{st_name:?}") }; + assert_eq!((swatch.as_deref(), *tint), (Some(vectorcraft_color::swatch::REGISTRATION), 0.0)); + + let st_spot = &styles["Shemen"]; + assert!(st_spot.plain); + let [StyleItem::Fill(Paint::Solid { swatch, tint, color })] = st_spot.items.as_slice() else { panic!("{st_spot:?}") }; + assert_eq!((swatch.as_deref(), *tint, *color), (Some("Shemen"), 1.0, Color::cmyk(0.0, 0.5, 1.0, 0.0))); + + let st_stroke = &styles["StrokeReg"]; + assert!(st_stroke.plain); + let [StyleItem::Stroke { paint: Paint::Solid { swatch, tint, .. }, .. }] = st_stroke.items.as_slice() else { panic!("{st_stroke:?}") }; + assert_eq!((swatch.as_deref(), *tint), (Some(vectorcraft_color::swatch::REGISTRATION), 0.0)); +} diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 5765fa6bf..07236c2c6 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -783,7 +783,7 @@ fn pattern_names_are_shown_up_to_their_newline() { // Nor the name of a custom color the program of an EPS gives, or None's. let ps = b"%!PS-Adobe-3.0\n%%CMYKCustomColor: 0 0 1 0 (Outer Ink)\n%%EndComments\n"; let data = stream(&format!("{}{}{}", def("Outer Ink\\n1", 8), def("[None]\\n1", 8), layer(&filled("Outer Ink\\n1")))); - let doc = super::read_beside(data.as_bytes(), true, ps).unwrap().doc; + let doc = super::read_beside(data.as_bytes(), true, ps, false).unwrap().doc; assert_eq!(doc.patterns.iter().map(|p| p.name.as_str()).collect::>(), ["Outer Ink 2", "[None] 2"]); assert!(matches!(doc.swatch("Outer Ink").unwrap().paint, Paint::Solid { .. })); assert!(matches!(fill(&art(&doc)[0]), Paint::Pattern { pattern, .. } if pattern == "Outer Ink 2")); @@ -2613,6 +2613,29 @@ fn a_drawn_look_that_is_an_image_stands_for_its_object() { assert!(r.warnings.iter().any(|w| w.contains("drawn look (1)")), "{:?}", r.warnings); } +/// Samples in hex comment lines after `XI` (the version 7 specification's form) are read up to the +/// bytes the image needs; an image past the hard limits isn't read, whatever lines follow it. +#[test] +fn hex_comment_line_samples_are_read_up_to_the_image() { + let raster = |(w, h): (u32, u32), lines: &str| { + let s = format!( + "%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(L) Ln\n%AI5_File:\n%AI5_BeginRaster\n() 1 XG\n/DeviceRGB XN\n\ + [ 1 0 0 1 0 100 ] {w} {h} 0 Xh\n[ 1 0 0 1 0 100 ] 0 0 {w} {h} {w} {h} 8 3 0 0 0 0 XI\n{lines}%AI5_EndRaster\nN\nLB\n%AI5_EndLayer--\n" + ); + let mut out = stream("").into_bytes(); + let at = out.windows(14).position(|w| w == b"%%PageTrailer\n").unwrap(); + out.splice(at..at, s.into_bytes()); + out + }; + // 2 × 1: red, green, then digits past the image's six bytes. + let r = read(&raster((2, 1), "%FF00\n%0000FF\n%00FFFFFF\n")).unwrap(); + let img = pixels(&r.doc).0; + assert_eq!((img.get_pixel(0, 0).0, img.get_pixel(1, 0).0), ([255, 0, 0, 255], [0, 255, 0, 255])); + let r = read(&raster((70_000, 70_000), "%FF0000\n%00FF00\n")).unwrap(); + assert!(art(&r.doc).is_empty()); + assert!(r.warnings.iter().any(|w| w == "an image too large to read from the editing data was left out"), "{:?}", r.warnings); +} + /// The name and opacity mask after an image follow the operators that end its raster: `XH`, the /// versioned `Xr` and the `N` after `%AI5_EndRaster`. #[test] @@ -2911,7 +2934,7 @@ fn a_version_7_linked_image_without_its_file_name_is_left_out_with_a_warning() { let r = read(stream7(&layer7("L", &raster7("(a.tif) 0 XG\n", m, (100_000, 100_000), "XF", ""))).as_bytes()).unwrap(); let a = art(&r.doc); assert!(matches!(&a[..], [n] if matches!(&n.kind, NodeKind::Image(im) if (im.width, im.height) == (100_000, 100_000))), "{a:?}"); - assert!(!r.warnings.iter().any(|w| w == read::IMAGE_TOO_LARGE), "{:?}", r.warnings); + assert!(!r.warnings.iter().any(|w| w == "an image too large to read from the editing data was left out"), "{:?}", r.warnings); let twice = raster7("(a.tif) 0 XG\n(b.tif) 0 XG\n", m, (2, 2), "XF", ""); let r = read(stream7(&layer7("L", &twice)).as_bytes()).unwrap(); let a = art(&r.doc); @@ -5778,6 +5801,111 @@ fn an_object_with_a_style_this_reads_comes_in_as_itself() { assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings); } +/// An art style named `name`: a blue fill under a red 2 pt stroke written as a Stroke Offset filter +/// whose `StrokeOffsetInside` entry is `inside` (`None`: left out). +fn offset_stroke_style(name: &str, inside: Option) -> String { + let switch = inside.map(|v| format!("{v} /Bool (StrokeOffsetInside) ,\n")).unwrap_or_default(); + offset_stroke_style_with(name, &switch, OFFSET_STROKE) +} + +/// The Stroke Offset stroke of [`offset_stroke_style`]: red, 2 pt, butt caps, miter joins, solid. +const OFFSET_STROKE: &str = "0 R\n0 1 1 0 1 0 0 XA\n0 J 0 j 2 w 10 M []0 d\n0 XR\n1 0 Xd"; + +/// An art style named `name`: a blue fill under a Stroke Offset filter whose dictionary holds +/// `entries` and the stroke style `stroke` (what follows `/StrokeStyle :`). +fn offset_stroke_style_with(name: &str, entries: &str, stroke: &str) -> String { + format!( + "/KnownStyle :\n({name}) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /BasicFilter :\n(Adobe Stroke Offset) 1 0 /Filter ,\n1 /Visible ,\n2 /FillOrStroke ,\n/Dictionary : /NotRecorded ,\n{entries}(Side) /String (DisplayString) ,\n\ + /StrokeStyle : {stroke}\n/Def ;\n (StrokeStyle) ,\n; /Dict ;\n /Part ,\n;\n /Part ,\n\ + /BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n/FillStyle : 0 O\n0 0 0 0 0 0 1 Xa\n0 1 Xd\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n\ + /BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n;\n /Part ,\n;\n" + ) +} + +#[test] +fn a_stroke_offset_filter_is_a_stroke_aligned_inside_or_outside() { + // GUARDS: a style's stroke aligned inside or outside (Stroke panel › Align Stroke) left the object as its drawn look. + use vectorcraft_doc::StrokeAlign; + for (inside, align) in [(Some(1), Some(StrokeAlign::Inside)), (Some(0), Some(StrokeAlign::Outside)), (None, None), (Some(2), None)] { + let r = read_ok(&format!("%AI9_BeginArtStyles\n{}%AI9_EndArtStyles\n{}", offset_stroke_style("Edge", inside), layer(&styled_square("Edge")))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + let stroke = a[0].appearance.stroke().map(|s| (s.align, s.width, s.paint.color())); + match align { + Some(align) => { + assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the object, not its look"); + assert_eq!(stroke, Some((align, 2.0, Some(Color::rgb(1.0, 0.0, 0.0))))); + assert_eq!(fill(&a[0]).color(), Some(Color::rgb(0.0, 0.0, 1.0))); + assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings); + } + // Without a 0/1 switch, which side it paints isn't known: the drawn look stays. + None => assert!(r.warnings.iter().any(|w| w.contains("drawn look")), "{inside:?}: {:?}", r.warnings), + } + } +} + +/// A Stroke Offset stroke keeps the caps, joins, miter limit, dashes and width profile of its +/// style, as a Stroke Style Filter's does. +#[test] +fn a_stroke_offset_filter_keeps_its_strokes_style() { + use vectorcraft_doc::StrokeAlign; + let profile = "/Dictionary : /NotRecorded ,\n/Array :\n0 /Real ,\n1 /Real ,\n; (BSVariableWidthTValuesArray) ,\n\ + /Array :\n1 /Real ,\n0.1 /Real ,\n; (BSCenteredWidthsArray) ,\n; (kAIBeautifulStrokesParamsDictKey) ,\n"; + let entries = format!("0 /Bool (StrokeOffsetInside) ,\n{profile}"); + let stroke = "0 R\n0 1 1 0 1 0 0 XA\n1 J 2 j 3 w 4 M [5 10 ]0 d\n0 XR\n1 0 Xd"; + let style = offset_stroke_style_with("Edge", &entries, stroke); + let r = read_ok(&format!("%AI9_BeginArtStyles\n{style}%AI9_EndArtStyles\n{}", layer(&styled_square("Edge")))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the object, not its look"); + let s = a[0].appearance.stroke().expect("a stroke"); + assert_eq!((s.align, s.width, s.cap, s.join, s.miter_limit), (StrokeAlign::Outside, 3.0, LineCap::Round, LineJoin::Bevel, 4.0)); + assert_eq!(s.dash.as_ref().map(|d| d.pattern.clone()), Some(vec![5.0, 10.0])); + assert_eq!(s.profile.as_ref().map(|p| p.points.clone()), Some(vec![(0.0, 1.0, 1.0), (1.0, 0.1, 0.1)])); +} + +/// A Stroke Offset stroke this doesn't read as a stroke keeps the object's drawn look: a switch +/// written as another kind, dashes aligned to corners and path ends, a gradient. +#[test] +fn a_stroke_offset_filter_with_a_stroke_this_does_not_read_keeps_the_drawn_look() { + let outside = "0 /Bool (StrokeOffsetInside) ,\n"; + let gradient = "0 R\nBb\n0 0 0 0 Bh\n2 (Red ramp) 0 0 90 1 1 0 0 1 0 0 1 Bg\n0 BB\n0 J 0 j 2 w 10 M []0 d\n0 XR\n1 0 Xd"; + let cases = [ + ("a switch written as a number", offset_stroke_style_with("Edge", "1 /Int (StrokeOffsetInside) ,\n", OFFSET_STROKE)), + ( + "dashes aligned to corners", + offset_stroke_style_with( + "Edge", + &format!("{outside}/Dictionary : /NotRecorded ,\n1 /Bool (BSDashAdj) ,\n; (kAIBeautifulStrokesParamsDictKey) ,\n"), + &OFFSET_STROKE.replace("[]0 d", "[5 10 ]0 d"), + ), + ), + ("a gradient", offset_stroke_style_with("Edge", outside, gradient)), + ]; + for (why, style) in cases { + let r = read_ok(&format!("%AI9_BeginArtStyles\n{style}%AI9_EndArtStyles\n{}", layer(&styled_square("Edge")))); + assert!(r.warnings.iter().any(|w| w.contains("drawn look")), "{why}: {:?}", r.warnings); + } +} + +/// A listed `[Default]` style whose stroke is aligned outside comes in as the default graphic +/// style with that stroke. +#[test] +fn a_default_style_with_a_stroke_aligned_outside_comes_in() { + use vectorcraft_doc::StrokeAlign; + let r = + read_ok(&format!("{}{}", styles_and_list(&[offset_stroke_style("[Default]", Some(0))], &["[Default]"]), layer(&styled_square("[Default]")))); + assert_eq!(style_names(&r.doc), [("[Default]".to_string(), 1)]); + let stroke = r.doc.graphic_styles[0].appearance.stroke().map(|s| (s.align, s.width)); + assert_eq!(stroke, Some((StrokeAlign::Outside, 2.0))); + let a = art(&r.doc); + assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the object, not its look"); + assert_eq!(a[0].extras().graphic_style, Some(1)); +} + #[test] fn a_symbol_defined_before_the_art_styles_reads_its_styled_objects() { // GUARDS: objects in a symbol defined ahead of `%AI9_BeginArtStyles` (as Illustrator 24 writes @@ -5964,6 +6092,30 @@ fn an_object_whose_transparency_differs_from_its_listed_style_is_not_linked() { assert_eq!(a[0].extras().graphic_style, None, "the style has full opacity"); } +#[test] +fn ai_import_registration_style_does_not_carry_over_previous_color_6205() { + // Regression test for #6205: + // A background shape sets a spot colour ("Shemen"). + // A following object has an art style with [Registration] at 0% tint. + // The object must resolve the Registration style ([Registration] 0% / #ffffff) + // rather than falling back to the prior spot color. + let defs = [fill_style("RegWhite", "1 1 1 1 ([Registration]) 1 Xs\n")]; + let background = format!("0 0.67 0.73 0.26 (Shemen) 0 0 Xx\n{}f\n", rect_path(0.0, 0.0, 100.0, 100.0)); + let styled_obj = styled_square("RegWhite"); + let content = format!("{background}{styled_obj}"); + let r = read_ok(&format!("{}{}", styles_and_list(&defs, &["RegWhite"]), layer(&content))); + let a = art(&r.doc); + assert_eq!(a.len(), 2, "{a:#?}"); + // First object is background in Shemen spot + let Paint::Solid { swatch: s0, .. } = fill(&a[0]) else { panic!("{:#?}", a[0]) }; + assert_eq!(s0.as_deref(), Some("Shemen")); + // Second object is styled in RegWhite: [Registration] at 0% tint (#ffffff) + let Paint::Solid { swatch, tint, color } = fill(&a[1]) else { panic!("{:#?}", a[1]) }; + assert_eq!(swatch.as_deref(), Some(vectorcraft_color::swatch::REGISTRATION)); + assert_eq!(tint, 0.0); + assert_eq!(color, Color::cmyk(1.0, 1.0, 1.0, 1.0).tinted(0.0)); +} + #[test] fn a_listed_style_keeps_its_transparency_and_links_objects_that_have_it() { let blend = "/BasicFilter :\n(Blend Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n\ @@ -7519,6 +7671,53 @@ fn vector_opacity_mask_keeps_its_art_and_does_not_paint_as_a_sibling() { } } +/// Mask art that sets no paint of its own paints with the paint before its dictionary: the writer +/// leaves out paint that doesn't change. In a user's file, white letters were masked by a rectangle +/// with no colour, which came in black and hid them. +/// The unread pattern flags it also takes aren't covered here: an owner painted with a pattern the +/// file doesn't have is unread itself, which hides whether its mask art is. +#[test] +fn opacity_mask_art_without_its_own_paint_takes_the_paint_before_its_dictionary() { + let paint = "0 0 0 0 k\n1 O\n0 1 0 0 K\n1 R\n2 w\n1 J\n1 j\n4 M\n[3 1] 0 d\n1 XR\n"; + let owner = format!("{paint}{}B\n", rect_path(10.0, 20.0, 50.0, 60.0)); + let hidden_rect = rect_path(10.0, 20.0, 30.0, 60.0).lines().map(|l| format!("%_{l}\n")).collect::(); + let mask = |own_paint: &str| { + format!("%_/ArtDictionary :\n%_/Mask :\n%_X=\n{own_paint}{hidden_rect}%_B\n%_X+\n%_(Art) ,\n%_1 /Bool (Clipping) ,\n%_; (AI9_Mask) ,\n%_;\n") + }; + let fill_layer = |n: &Node| n.appearance.fill().cloned(); + let stroke_layer = |n: &Node| n.appearance.stroke().cloned(); + let rule = |n: &Node| match &n.kind { + NodeKind::Path { rule, .. } => Some(*rule), + _ => None, + }; + + let r = read_ok(&layer(&format!("{owner}{}", mask("")))); + let a = art(&r.doc); + let m = a[0].extras().mask.as_ref().unwrap(); + // The owner has the paint set, none of it the default, so equal layers mean it was taken. + let (f, s) = (fill_layer(&a[0]).unwrap(), stroke_layer(&a[0]).unwrap()); + assert!(f.overprint && s.overprint); + assert_eq!((s.width, s.cap, s.join, s.miter_limit), (2.0, LineCap::Round, LineJoin::Round, 4.0)); + assert_eq!(s.dash.as_ref().map(|d| (d.pattern.clone(), d.offset)), Some((vec![3.0, 1.0], 0.0))); + assert_eq!(rule(&a[0]), Some(vectorcraft_geom::FillRule::EvenOdd)); + assert_eq!(fill_layer(&m.art), Some(f)); + assert_eq!(stroke_layer(&m.art), Some(s)); + assert_eq!(rule(&m.art), rule(&a[0])); + + // Paint the mask's art sets for itself stays in the mask: an object after the dictionary that + // sets none paints with the owner's. + let own = "%_1 g\n%_0 O\n%_0 G\n%_0 R\n%_5 w\n%_0 J\n%_0 j\n%_10 M\n%_[] 0 d\n%_0 XR\n"; + let following = format!("{}B\n", rect_path(70.0, 20.0, 80.0, 30.0)); + let r = read_ok(&layer(&format!("{owner}{}{following}", mask(own)))); + let a = art(&r.doc); + assert_eq!(a.len(), 2); + let m = a[0].extras().mask.as_ref().unwrap(); + assert_ne!(stroke_layer(&m.art), stroke_layer(&a[0]), "the mask's art has its own paint"); + assert_eq!(fill_layer(&a[1]), fill_layer(&a[0])); + assert_eq!(stroke_layer(&a[1]), stroke_layer(&a[0])); + assert_eq!(rule(&a[1]), rule(&a[0])); +} + /// An opacity mask's art of one shown object is that object; other art is read into a group. #[test] fn opacity_mask_art_of_one_object_is_that_object() { diff --git a/crates/eps/src/import/graphics.rs b/crates/eps/src/import/graphics.rs index a21d6c9f6..243187d6b 100644 --- a/crates/eps/src/import/graphics.rs +++ b/crates/eps/src/import/graphics.rs @@ -42,7 +42,7 @@ const MAX_DASH_ENTRIES: usize = 65_535; /// Most entries an indexed colour space's table has. const MAX_HIVAL: usize = 4095; /// Most elements the current path may have. -const MAX_PATH: usize = 1 << 20; +pub(crate) const MAX_PATH: usize = 1 << 20; /// Most path elements drawn in all (each object's clips and groups counting as elements too). const MAX_DRAWN: usize = 1 << 24; /// Samples taken of a shading function that isn't a plain interpolation. diff --git a/crates/eps/src/import/graphics/path.rs b/crates/eps/src/import/graphics/path.rs index f9580077e..3d4f244dc 100644 --- a/crates/eps/src/import/graphics/path.rs +++ b/crates/eps/src/import/graphics/path.rs @@ -193,7 +193,7 @@ impl Interp<'_> { // ---------- painting ---------- - pub(super) fn fill_path(&mut self, bp: BezPath, rule: FillRule) -> Res { + pub(in super::super) fn fill_path(&mut self, bp: BezPath, rule: FillRule) -> Res { if bp.elements().is_empty() || self.g.null { return Ok(()); } diff --git a/crates/eps/src/import/interp.rs b/crates/eps/src/import/interp.rs index fe0c63919..d3d9c2bdd 100644 --- a/crates/eps/src/import/interp.rs +++ b/crates/eps/src/import/interp.rs @@ -15,8 +15,11 @@ use super::graphics::{GState, Out}; use super::lex::{IMMEDIATE, Lexer, number}; use super::obj::{Dict, DictRef, Key, Obj, Op, PsError, Res, Shared, ps_err}; -/// Most operations one file may run. +/// Most operations one file may run, whatever its length. const MAX_OPS: u64 = 50_000_000; +/// More operations a file may run for each byte of its program: a large file that draws a lot +/// runs proportionally more, and stays bounded by its length. +const OPS_PER_BYTE: u64 = 64; /// Deepest procedure nesting. const MAX_DEPTH: u32 = 400; /// Most operands on the stack. @@ -55,6 +58,8 @@ pub(crate) struct Interp<'a> { resources: DictRef, depth: u32, ops: u64, + /// Most operations this program may run: [`MAX_OPS`] and [`OPS_PER_BYTE`] for each byte. + max_ops: u64, /// Bytes asked for so far (see [`MAX_MEMORY`]). allocated: usize, seed: u32, @@ -68,6 +73,12 @@ pub(crate) struct Interp<'a> { pub fault: Option, /// The width a Type 3 glyph procedure gave (`setcachedevice`, `setcharwidth`). pub glyph_width: Option<[f64; 2]>, + /// The TrueType data of the Type 42 fonts drawn so far, by their `sfnts` array (see + /// `type42`; `None` when it can't be read), the most recently used last. + pub type42: Type42Cache, + /// The `sfnts` arrays whose TrueType data has been counted against [`MAX_MEMORY`]: a font + /// read again after it left `type42` doesn't count again. + pub type42_counted: HashSet>, pub g: GState, pub saved: Vec, pub out: Out, @@ -89,6 +100,9 @@ pub(crate) struct Fault { pub procs: Vec>, } +/// The TrueType data of Type 42 fonts by their `sfnts` array (see [`Interp::type42`]). +pub(crate) type Type42Cache = Vec<(Shared, Option>>)>; + /// Most procedure names a [`Fault`] keeps. const FAULT_PROCS: usize = 3; @@ -177,12 +191,15 @@ impl<'a> Interp<'a> { resources, depth: 0, ops: 0, + max_ops: MAX_OPS.saturating_add((src.len() as u64).saturating_mul(OPS_PER_BYTE)), allocated: 0, seed: 1, saves: vec![], journal: vec![], fault: None, glyph_width: None, + type42: vec![], + type42_counted: HashSet::new(), g, saved: vec![], out, @@ -356,15 +373,15 @@ impl<'a> Interp<'a> { /// Charge for work on `n` bytes or objects in one operator: one operation per 256, as `search` /// charges per 64 bytes, so a loop of big copies runs out of the budget. - fn spend_bytes(&mut self, n: usize) -> Res { + pub fn spend_bytes(&mut self, n: usize) -> Res { self.spend((n / 256) as u64) } - /// Count `ops` operations' worth of work against [`MAX_OPS`]: what an operator that costs + /// Count `ops` operations' worth of work against the budget (`max_ops`): what an operator that costs /// far more than one operation (laying out type) does in a loop has to end too. pub fn spend(&mut self, ops: u64) -> Res { self.ops = self.ops.saturating_add(ops); - if self.ops > MAX_OPS { + if self.ops > self.max_ops { return Err(PsError::Limit("the program runs too long")); } Ok(()) diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs index 7e6dd4cfc..57d961181 100644 --- a/crates/eps/src/import/native/build.rs +++ b/crates/eps/src/import/native/build.rs @@ -59,7 +59,9 @@ pub(super) fn build( ) -> Result<(Document, Vec), String> { // An EPS (its page is its art) draws a Large Canvas page at full size; a `.ai` keeps its art // space, as its PDF part may (#2031). - build_from(ai::read(data, matches!(page, Page::Art))?, data, visible, page, outlined, trust, unit) + let large_canvas = matches!(page, Page::Art); + let structure = if visible.is_some() { ai::read_over_page(data, large_canvas)? } else { ai::read(data, large_canvas)? }; + build_from(structure, data, visible, page, outlined, trust, unit) } /// [`build`] without a page, for a `.ai` whose PDF part can't be read, its first page `page` diff --git a/crates/eps/src/import/native/layers.rs b/crates/eps/src/import/native/layers.rs index 0575eebcc..597979855 100644 --- a/crates/eps/src/import/native/layers.rs +++ b/crates/eps/src/import/native/layers.rs @@ -24,7 +24,7 @@ pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool, illus // can't be used. // Private data kept apart from the program: the program's own header gives the author and // custom colors the private data's header may lack. - let read = |d: &[u8]| if matches!(data, Ok(Cow::Owned(_))) { ai::read_beside(d, true, ps) } else { ai::read(d, true) }; + let read = |d: &[u8]| if matches!(data, Ok(Cow::Owned(_))) { ai::read_beside(d, true, ps, true) } else { ai::read_over_page(d, true) }; let (tree, result) = match data.as_deref() { Ok(d) => match read(d) { Ok(s) => (layer_tree(&s.doc.layers), build_from(s, d, Some(&visible.document), Page::Art, false, trust, 1.0)), @@ -160,14 +160,9 @@ pub fn layered_ai_from( (visible, all, false) }; let built = structure - .unwrap_or_else(|| ai::read(&data, false).map(Box::new)) + .unwrap_or_else(|| ai::read_over_page(&data, false).map(Box::new)) .and_then(|structure| build_from(*structure, &data, Some(&visible), Page::Artboard, outlined, trust, unit)); match built { - // An image too large for the editing data's reader would be missing from the layers, while - // the PDF part has it: the file comes in as its PDF part instead (#1479). - Ok((_, notes)) if notes.iter().any(|n| n == ai::IMAGE_TOO_LARGE) => { - from_pdf_part(visible, warnings, "an image is too large to read from them") - } Ok((mut done, notes)) => { done.setup.bleed = visible.setup.bleed; let mut all = warnings; @@ -193,14 +188,13 @@ pub fn ai_layers_alone(private: &[u8], pages: &[(f64, f64)], unit: f64) -> Resul if !has_layers(&data) { return Err(AiLayers { data: Ok(data), structure: None }); } - let structure = match ai::read(&data, false) { + // An image past the hard limits is read as an unread box, which the PDF part's art replaces. + let structure = match ai::read_over_page(&data, false) { Ok(structure) => structure, Err(why) => return Err(AiLayers { data: Ok(data), structure: Some(Err(why)) }), }; match build_without_page(structure, &data, pages, unit) { - // An image too large for the editing data's reader is missing from the layers: the PDF - // part has it (as in `layered_ai`). - Ok(Ok((doc, notes))) if !notes.iter().any(|n| n == ai::IMAGE_TOO_LARGE) => Ok((doc, notes)), + Ok(Ok((doc, notes))) => Ok((doc, notes)), Ok(_) => Err(AiLayers { data: Ok(data), structure: None }), Err(structure) => Err(AiLayers { data: Ok(data), structure: Some(Ok(structure)) }), } diff --git a/crates/eps/src/import/tests.rs b/crates/eps/src/import/tests.rs index 2bda9c230..601c64cb5 100644 --- a/crates/eps/src/import/tests.rs +++ b/crates/eps/src/import/tests.rs @@ -1125,12 +1125,29 @@ fn read_within(body: &str, secs: u64) -> crate::Imported { /// budget. Running to the end of the budget can take over 20 s on a slow CI runner /// (`search_in_a_loop_runs_out_of_the_budget` searches about 3 GB, 14 s on a fast desktop), so the /// limit here only catches a program that never stops. -fn runs_out_of_budget(body: &str) { +pub(super) fn runs_out_of_budget(body: &str) { let r = read_within(&format!("0 0 10 10 rectfill {body}"), 120); assert_eq!(objects(&r.document).len(), 1, "{body}: {:?}", r.warnings); assert!(r.warnings.iter().any(|w| w.contains("the program runs too long")), "{body}: {:?}", r.warnings); } +/// A large file that runs far more operations than a small one may, but only a few hundred for +/// each object it draws, reads to its end: the operation budget grows with the program's length. +#[test] +fn a_large_file_with_many_objects_reads_to_its_end() { + let n = 200_000; + let mut body = String::from( + "/sq { 4 dict begin /y exch def /x exch def 0 1 200 { pop } for \ + x y moveto x 1 add y lineto x y 1 add lineto closepath fill end } def\n", + ); + for i in 0..n { + body.push_str(&format!("{} {} sq\n", i % 100, i / 2000)); + } + let r = read_within(&body, 300); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + assert_eq!(objects(&r.document).len(), n); +} + /// A loop of operators whose work grows with the path they make runs out of the operation budget /// (PLRM 3rd ed., chapter 8): `arc` and `arct` with a huge radius, which make thousands of curve /// segments, `strokepath` with a dash far finer than the line, dashes of length zero included, and diff --git a/crates/eps/src/import/tests_composite.rs b/crates/eps/src/import/tests_composite.rs new file mode 100644 index 000000000..ddf86acc5 --- /dev/null +++ b/crates/eps/src/import/tests_composite.rs @@ -0,0 +1,253 @@ +//! Composite (Type 0) fonts with the 8/8 mapping, as PDF-to-PostScript converters write a PDF's +//! two-byte fonts: each character a byte picking the descendant font and a byte for the code in +//! it (PLRM 3rd ed., §5.10). Type 42 descendants draw their glyphs from the TrueType font they +//! carry (Adobe Technical Note #5012); the fonts here are written from scratch around the bundled +//! Source Sans 3. + +use skrifa::instance::{LocationRef, Size}; +use skrifa::{FontRef, MetadataProvider}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::NodeKind; + +use super::tests::{all, bounds, fill, read}; + +const FONT: &[u8] = include_bytes!("../../../../assets/fonts/SourceSans3-Regular.ttf"); + +/// The glyph index of `c` in [`FONT`] and its advance width in ems. +fn glyph(c: char) -> (u32, f64) { + let font = FontRef::new(FONT).unwrap(); + let gid = font.charmap().map(c).unwrap(); + let upem = f64::from(font.metrics(Size::unscaled(), LocationRef::default()).units_per_em); + let adv = font.glyph_metrics(Size::unscaled(), LocationRef::default()).advance_width(gid).unwrap(); + (gid.to_u32(), f64::from(adv) / upem) +} + +/// `sfnts` strings of [`FONT`]: the first of odd length, ending in a padding byte that isn't part +/// of the font, the rest split at even lengths. +fn sfnts() -> String { + let hex = |b: &[u8]| b.iter().map(|x| format!("{x:02x}")).collect::(); + let mut out = format!("<{}00>\n", hex(&FONT[..20000])); + for chunk in FONT[20000..].chunks(40000) { + out.push_str(&format!("<{}>\n", hex(chunk))); + } + out +} + +/// A program defining Type 42 font `/T42` (codes 1 and 2 are H and I, by glyph names that +/// don't say which characters they are) and composite font `/Comp` over it. +fn fonts() -> String { + let (h, _) = glyph('H'); + let (i, _) = glyph('I'); + format!( + "10 dict begin /FontName /T42 def /FontType 42 def /FontMatrix [1 0 0 1 0 0] def /FontBBox [0 0 1 1] def /PaintType 0 def\n\ + /Encoding 256 array def 0 1 255 {{ Encoding exch /.notdef put }} for Encoding 1 /c01 put Encoding 2 /c02 put\n\ + /CharStrings 3 dict def CharStrings begin /.notdef 0 def /c01 {h} def /c02 {i} def end\n\ + /sfnts [\n{}] def FontName currentdict end definefont pop\n\ + 16 dict begin /FontName /Comp def /FontType 0 def /FontMatrix [1 0 0 1 0 0] def /FMapType 2 def\n\ + /Encoding [0] def /FDepVector [/T42 findfont] def FontName currentdict end definefont pop\n", + sfnts() + ) +} + +#[test] +fn type42_descendants_draw_their_own_glyphs() { + let (_, wh) = glyph('H'); + let (_, wi) = glyph('I'); + let r = read(&format!("{}/Comp findfont 50 scalefont setfont 0 0 1 setrgbcolor 10 20 moveto (\\000\\001\\000\\002) show", fonts())); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + let nodes = all(&r.document); + assert!(!nodes.iter().any(|n| matches!(n.kind, NodeKind::Text(_)))); + let paths: Vec<_> = nodes.iter().filter(|n| matches!(n.kind, NodeKind::Path { .. })).collect(); + assert_eq!(paths.len(), 2); + for p in &paths { + assert_eq!(fill(p), Some(&Paint::solid(Color::rgb(0.0, 0.0, 1.0)))); + } + // The H, then the I one H's width along; both stand on the baseline, a cap height tall. + let (bh, bi) = (bounds(paths[0]), bounds(paths[1])); + assert!((bh.x0 - 10.0).abs() < 8.0 && bh.width() > 20.0 && bh.width() < 50.0 * wh, "{bh:?}"); + assert!((bh.height() - bi.height()).abs() < 0.5 && bh.height() > 25.0 && bh.height() < 40.0, "{bh:?} {bi:?}"); + assert!(bi.x0 > 10.0 + 50.0 * wh - 1.0 && bi.x1 < 10.0 + 50.0 * (wh + wi), "{bi:?}"); +} + +#[test] +fn type42_descendants_measure_space_and_outline_their_glyphs() { + let (_, wh) = glyph('H'); + let (_, wi) = glyph('I'); + let check = |cond: String| super::tests::check(&format!("{} /Comp findfont 50 scalefont setfont {cond}", fonts())); + // The width its font gives each glyph. + check(format!("(\\000\\001\\000\\002) stringwidth pop {} sub abs 0.01 lt", 50.0 * (wh + wi))); + // xyshow places each character where its numbers say. + check("gsave nulldevice 10 20 moveto (\\000\\001\\000\\002) [30 0 7 0] xyshow currentpoint grestore pop 47 sub abs 0.01 lt".into()); + // charpath outlines them. + check("newpath 10 20 moveto (\\000\\001) true charpath pathbbox exch pop sub abs 25 gt exch 10 sub abs 8 lt and".into()); +} + +/// A descendant that is a font by name only (its program isn't run) sets its characters as type, +/// at the size the composite font's matrix gives them, not at 1000 times it. +#[test] +fn named_descendants_set_type_at_the_composite_fonts_size() { + let r = read( + "16 dict begin /FontName /Comp def /FontType 0 def /FontMatrix [1 0 0 1 0 0] def /FMapType 2 def\n\ + /Encoding [0] def /FDepVector [/Helvetica findfont] def FontName currentdict end definefont pop\n\ + /Comp findfont 30 scalefont setfont 10 20 moveto (\\000A\\000B) show", + ); + let sizes: Vec = all(&r.document) + .iter() + .filter_map(|n| match &n.kind { + NodeKind::Text(t) => Some(t.runs.iter().map(|r| r.style.size * t.xf.determinant().abs().sqrt()).sum::() / t.runs.len() as f64), + _ => None, + }) + .collect(); + assert!(!sizes.is_empty() && sizes.iter().all(|s| (s - 30.0).abs() < 0.5), "{sizes:?}"); + let bounds_all = all(&r.document).iter().map(|n| bounds(n)).reduce(|a, b| a.union(b)).unwrap(); + assert!(bounds_all.width() < 60.0, "{bounds_all:?}"); +} + +/// `widthshow` adds its spacing to the composite character whose font number times 256 plus code +/// is its `char` (PLRM 3rd ed., `widthshow` in chapter 8), not to every character with that code. +#[test] +fn widthshow_matches_a_composite_character_by_font_number_and_code() { + let (_, wh) = glyph('H'); + let fonts = fonts().replace("/Encoding [0] def", "/Encoding [0 0] def"); + let check = |cond: String| super::tests::check(&format!("{fonts} /Comp findfont 50 scalefont setfont {cond}")); + let end = |ch: u32| format!("gsave nulldevice 10 20 moveto 20 0 {ch} (\\000\\001\\001\\001) widthshow currentpoint grestore pop"); + // 257 is font 1's code 1: the second H only. + check(format!("{} {} sub abs 0.01 lt", end(257), 10.0 + 100.0 * wh + 20.0)); + // 1 is font 0's code 1: the first H only. + check(format!("{} {} sub abs 0.01 lt", end(1), 10.0 + 100.0 * wh + 20.0)); + // 2 is neither. + check(format!("{} {} sub abs 0.01 lt", end(2), 10.0 + 100.0 * wh)); +} + +/// Measuring a composite font's Type 42 glyphs (`stringwidth` on a long string in a `loop`) +/// counts against the operation budget, as drawing them does. +#[test] +fn measuring_type42_descendants_in_a_loop_runs_out_of_the_budget() { + super::tests::runs_out_of_budget(&format!( + "{} /Comp findfont 50 scalefont setfont /s 100000 string def {{ s stringwidth pop pop }} loop", + fonts() + )); +} + +/// A descendant whose TrueType data can't be read (an `sfnts` element that isn't a string, after +/// 32 MB of strings) is read once, not again for every character measured: copying its strings +/// costs about 130,000 operations, so 1,000 measures finish only if it is copied once (reading +/// it each time would need 130 million, over the budget of 50 million). +#[test] +fn unreadable_type42_data_is_read_once() { + super::tests::check( + "10 dict begin /FontName /Bad def /FontType 42 def /FontMatrix [1 0 0 1 0 0] def /FontBBox [0 0 1 1] def /PaintType 0 def\n\ + /Encoding 256 array def 0 1 255 { Encoding exch /.notdef put } for\n\ + /CharStrings 1 dict def CharStrings /.notdef 0 put /sfnts [ 0 1 7 { pop 4000000 string } for 0 ] def FontName currentdict end definefont pop\n\ + 16 dict begin /FontName /Comp def /FontType 0 def /FontMatrix [1 0 0 1 0 0] def /FMapType 2 def\n\ + /Encoding [0] def /FDepVector [/Bad findfont] def FontName currentdict end definefont pop\n\ + /Comp findfont 50 scalefont setfont 1000 { (\\000\\001) stringwidth pop pop } repeat true", + ); +} + +/// A Type 3 descendant draws and measures its glyphs with its own procedure, under the composite +/// font's matrix: here 50 pt and twice as wide, so a glyph 500 × 700 units wide (600 units +/// apart) is 50 × 35 pt, 60 pt apart (PLRM 3rd ed., §5.10: a descendant's glyphs are under the +/// composite font's matrix as well as its own). +#[test] +fn type3_descendants_draw_and_measure_under_the_composite_matrix() { + let fonts = "8 dict begin /FontName /T3 def /FontType 3 def /FontMatrix [0.001 0 0 0.001 0 0] def /FontBBox [0 0 1000 1000] def\n\ + /Encoding 256 array def 0 1 255 { Encoding exch /.notdef put } for Encoding 1 /box put\n\ + /BuildChar { exch begin 1 eq { 600 0 0 0 500 700 setcachedevice 0 0 500 700 rectfill } { 0 0 setcharwidth } ifelse end } def\n\ + FontName currentdict end definefont pop\n\ + 16 dict begin /FontName /Comp def /FontType 0 def /FontMatrix [2 0 0 1 0 0] def /FMapType 2 def\n\ + /Encoding [0] def /FDepVector [/T3 findfont] def FontName currentdict end definefont pop\n\ + /Comp findfont 50 scalefont setfont"; + let r = read(&format!("{fonts} 0 0 1 setrgbcolor 10 20 moveto (\\000\\001\\000\\001) show")); + let paths: Vec<_> = all(&r.document).into_iter().filter(|n| matches!(n.kind, NodeKind::Path { .. })).collect(); + assert_eq!(paths.len(), 2, "{:?}", r.warnings); + let (a, b) = (bounds(&paths[0]), bounds(&paths[1])); + for g in [a, b] { + assert!((g.width() - 50.0).abs() < 0.01 && (g.height() - 35.0).abs() < 0.01, "{g:?}"); + } + assert!((a.x0 - 10.0).abs() < 0.01 && (b.x0 - 70.0).abs() < 0.01, "{a:?} {b:?}"); + let check = |cond: &str| super::tests::check(&format!("{fonts} {cond}")); + check("(\\000\\001\\000\\001) stringwidth pop 120 sub abs 0.01 lt"); + check("newpath 10 20 moveto (\\000\\001) true charpath currentpoint pop 70 sub abs 0.01 lt"); +} + +/// A descendant found by name is scaled for each character it sets: copying a huge font +/// dictionary for every character counts against the operation budget, so a `loop` of it stops. +#[test] +fn a_huge_named_descendant_in_a_loop_runs_out_of_the_budget() { + super::tests::runs_out_of_budget( + "200000 dict begin 0 1 199999 { dup def } for /FontName /Big def /FontType 1 def /FontMatrix [0.001 0 0 0.001 0 0] def\n\ + FontName currentdict end definefont pop\n\ + 16 dict begin /FontName /Comp def /FontType 0 def /FontMatrix [1 0 0 1 0 0] def /FMapType 2 def\n\ + /Encoding [0] def /FDepVector [/Big findfont] def FontName currentdict end definefont pop\n\ + /Comp findfont 50 scalefont setfont { (\\000A) stringwidth pop pop } loop", + ); +} + +/// A composite font's TrueType data stays read while it is in use: the font a string uses for +/// every other character isn't dropped (and read again, counting its size against the memory +/// budget each time) while 16 other fonts are read between its uses. +#[test] +fn type42_data_in_use_stays_read() { + let mut others = String::new(); + for k in 1..=16 { + others.push_str(&format!( + "10 dict begin /FontName /B{k} def /FontType 42 def /FontMatrix [1 0 0 1 0 0] def /FontBBox [0 0 1 1] def /PaintType 0 def\n\ + /Encoding 256 array def 0 1 255 {{ Encoding exch /.notdef put }} for\n\ + /CharStrings 1 dict def CharStrings /.notdef 0 put /sfnts [<00>] def FontName currentdict end definefont pop\n" + )); + } + let deps: String = (1..=16).map(|k| format!(" /B{k} findfont")).collect(); + let fonts = fonts().replace( + "/Encoding [0] def /FDepVector [/T42 findfont] def", + &format!("/Encoding [0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16] def /FDepVector [/T42 findfont{deps}] def"), + ); + let s: String = (1..=16).map(|k| format!("\\000\\001\\{k:03o}\\001")).collect(); + super::tests::check(&format!("{others}{fonts} /Comp findfont 50 scalefont setfont 3000 {{ ({s}) stringwidth pop pop }} repeat true")); +} + +/// A page that cycles through more Type 42 fonts than are kept read reads each again when it +/// comes back, but counts its data against the memory budget once. +#[test] +fn type42_data_read_again_counts_once() { + let mut fonts = format!("/S [\n{}] def\n", sfnts()); + for k in 0..17 { + fonts.push_str(&format!( + "10 dict begin /FontName /F{k} def /FontType 42 def /FontMatrix [1 0 0 1 0 0] def /FontBBox [0 0 1 1] def /PaintType 0 def\n\ + /Encoding 256 array def 0 1 255 {{ Encoding exch /.notdef put }} for\n\ + /CharStrings 1 dict def CharStrings /.notdef 0 put /sfnts [ S aload pop ] def FontName currentdict end definefont pop\n" + )); + } + let deps: String = (0..17).map(|k| format!(" /F{k} findfont")).collect(); + let picks: String = (0..17).map(|k| format!(" {k}")).collect(); + fonts.push_str(&format!( + "16 dict begin /FontName /Comp def /FontType 0 def /FontMatrix [1 0 0 1 0 0] def /FMapType 2 def\n\ + /Encoding [{picks}] def /FDepVector [{deps}] def FontName currentdict end definefont pop\n" + )); + let s: String = (0..17).map(|k| format!("\\{k:03o}\\001")).collect(); + // 17 fonts of 431 kB, each read 200 times: 1.4 GB if each read counted. + super::tests::check(&format!("{fonts} /Comp findfont 50 scalefont setfont 200 {{ ({s}) stringwidth pop pop }} repeat true")); +} + +/// Reading `sfnts` data that turns out unreadable is charged too: a composite font cycling +/// through more such descendants than are kept read copies their strings on every glyph, and a +/// `loop` of it runs out of the budget. +#[test] +fn unreadable_type42_data_read_again_runs_out_of_the_budget() { + let mut fonts = String::from("/S [ 0 1 7 { pop 4000000 string } for ] def\n"); + for k in 0..17 { + fonts.push_str(&format!( + "10 dict begin /FontName /Bad{k} def /FontType 42 def /FontMatrix [1 0 0 1 0 0] def /FontBBox [0 0 1 1] def /PaintType 0 def\n\ + /Encoding 256 array def 0 1 255 {{ Encoding exch /.notdef put }} for\n\ + /CharStrings 1 dict def CharStrings /.notdef 0 put /sfnts [ S aload pop 0 ] def FontName currentdict end definefont pop\n" + )); + } + let deps: String = (0..17).map(|k| format!(" /Bad{k} findfont")).collect(); + let picks: String = (0..17).map(|k| format!(" {k}")).collect(); + let s: String = (0..17).map(|k| format!("\\{k:03o}\\001")).collect(); + super::tests::runs_out_of_budget(&format!( + "{fonts}16 dict begin /FontName /Comp def /FontType 0 def /FontMatrix [1 0 0 1 0 0] def /FMapType 2 def\n\ + /Encoding [{picks}] def /FDepVector [{deps}] def FontName currentdict end definefont pop\n\ + /Comp findfont 50 scalefont setfont {{ ({s}) stringwidth pop pop }} loop" + )); +} diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 32c4ff796..329c5fb86 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -1215,27 +1215,89 @@ fn in_points(mut d: Document, k: f64) -> Document { d } -/// #1479: an image larger than the editing data's reader takes would be missing from the layers, -/// while the PDF part has it (it reads larger images): the file comes in as its PDF part, with a -/// note saying why, rather than without the image. +/// A 1-bit gray image of `w` × `h` pixels in the format's operators, its samples `rows` (binary, +/// rows padded to whole bytes, a set bit white), placed by the matrix `m`. +fn raster_1bit(m: &str, (w, h): (u32, u32), rows: &str) -> String { + format!( + "%AI5_File:\n%AI5_BeginRaster\n() 1 XG\n/DeviceGray XN\n[ {m} ] {w} {h} 0 Xh\n\ + [ {m} ] 0 0 {w} {h} {w} {h} 1 1 0 0 1 0 4 4 0 0\n%%BeginData: {}\rXI\n{rows}%%EndData\r\nXH\r\n%AI5_EndRaster\r\nN", + rows.len() + ) +} + +/// #1479: an image past 2^25 pixels keeps the layers, at full size; a 1-bit one is a 1-bit PNG of +/// its rows. #[test] -fn an_ai_file_with_an_image_too_large_for_its_editing_data_comes_in_as_its_pdf_part() { - let raster = |w: u32, h: u32| { - format!( - "%AI5_File:\n%AI5_BeginRaster\n() 1 XG\n/DeviceGray XN\n[ 1 0 0 1 0 100 ] {w} {h} 0 Xh\n\ - [ 1 0 0 1 0 100 ] 0 0 {w} {h} {w} {h} 8 1 0 0 1 0 4 4 0 0\n%%BeginData: 1\rXI\n\u{0}%%EndData\r\nXH\r\n%AI5_EndRaster\r\nN" - ) - }; - // 6,000 × 6,000 pixels: past the reader's 2^25. - let art = native(&layer("Texture", true, &format!("{}\n{}", square(10, 10), raster(6000, 6000)))); +fn an_ai_file_with_an_image_past_2_25_pixels_keeps_its_layers() { + // 6,000 × 6,000 pixels, each row black and white in turn ('U' is 01010101). + let image = raster_1bit("0.0005 0 0 0.0005 50 14", (6000, 6000), &"U".repeat(750 * 6000)); + let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Texture", true, &image))); let (d, warnings, read) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, true, 1.0); - assert!(!read, "the PDF part is used: {warnings:?}"); - assert!(warnings.iter().any(|w| w.contains("an image is too large to read from them")), "{warnings:?}"); - assert_eq!(d.layers[0].children().map_or(0, |c| c.len()), 1, "the PDF part's art"); - // An image the reader takes keeps the layers. - let art = native(&layer("Texture", true, &format!("{}\n{}", square(10, 10), raster(1, 1)))); - let (_, warnings, read) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, true, 1.0); assert!(read, "{warnings:?}"); + assert_eq!(names(&d), ["Live", "Texture"]); + assert_eq!(kinds(&d.layers[0]), ["path"]); + let [image] = d.layers[1].children().unwrap().as_slice() else { panic!("{:?}", d.layers[1]) }; + let NodeKind::Image(im) = &image.kind else { panic!("{image:?}") }; + assert_eq!((im.width, im.height), (6000, 6000)); + let png = &d.images[&im.key].bytes; + // Bit depth 1, colour type 0 (gray). + assert_eq!(png.get(24..26), Some([1, 0].as_slice())); + let gray = image::load_from_memory(png).unwrap().to_luma8(); + assert_eq!((gray.get_pixel(0, 0)[0], gray.get_pixel(1, 0)[0], gray.get_pixel(5999, 5999)[0]), (0, 255, 255)); + assert!(warnings.iter().all(|w| !w.contains("image")), "{warnings:?}"); +} + +/// An image past the target size is averaged down by the smallest whole step that fits, a 1-bit +/// one into gray coverage, the blocks at its edges over the pixels they have; it covers the box +/// the full size would. +#[test] +fn an_ai_image_past_the_target_size_is_reduced_and_keeps_the_layers() { + // 17 × 13 pixels: rows black, and in turn (the first one) white but for columns 0, 8 and 16. + let rows: String = (0..13).map(|y| if y % 2 == 0 { "\u{7f}\u{7f}\0" } else { "\0\0\0" }).collect(); + let image = raster_1bit("1 0 0 1 50 14", (17, 13), &rows); + let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Texture", true, &image))); + let (d, warnings, read) = crate::import::ai::with_image_limits(20, 1 << 31, 512 << 20, || { + crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, true, 1.0) + }); + assert!(read, "{warnings:?}"); + assert_eq!(names(&d), ["Live", "Texture"]); + assert!(warnings.contains(&"an embedded image of 17 × 13 pixels was reduced to 5 × 4 pixels to keep the layers".to_string()), "{warnings:?}"); + let [image] = d.layers[1].children().unwrap().as_slice() else { panic!("{:?}", d.layers[1]) }; + let NodeKind::Image(im) = &image.kind else { panic!("{image:?}") }; + assert_eq!((im.width, im.height), (5, 4)); + let full = im.xf.transform_rect_bbox(vectorcraft_geom::Rect::new(0.0, 0.0, 5.0, 4.0)); + assert!( + (full.x0 - 50.0).abs() < 1e-9 && (full.x1 - 67.0).abs() < 1e-9 && (full.y0 - 86.0).abs() < 1e-9 && (full.y1 - 99.0).abs() < 1e-9, + "{full:?}" + ); + let gray = image::load_from_memory(&d.images[&im.key].bytes).unwrap().to_luma8(); + // Steps of 4: blocks of rows 0–11 are half white rows, the last block row 12 alone; blocks of + // columns 0–3 and 8–11 have 3 white columns of 4, and the last block column 16 alone. + let rows: Vec> = (0..4).map(|y| (0..5).map(|x| gray.get_pixel(x, y)[0]).collect()).collect(); + assert_eq!(rows, [vec![96, 128, 96, 128, 0], vec![96, 128, 96, 128, 0], vec![96, 128, 96, 128, 0], vec![191, 255, 191, 255, 0]]); +} + +/// An image past the hard limits is a box that the page's art inside it replaces, and the +/// layers stay; without page art (the editing data alone), it is left out with a note. +#[test] +fn an_ai_image_past_the_hard_limits_comes_in_as_the_pages_art() { + let image = raster_1bit("1 0 0 1 50 14", (4, 4), &"\0".repeat(4)); + let art = native(&(layer("Live", true, &square(10, 10)) + &layer("Texture", true, &image))); + let (d, notes, read) = + crate::import::ai::with_image_limits(20, 15, 512 << 20, || crate::layered_ai(&private(&art), pdf_two_squares(), vec![], false, false, 1.0)); + assert!(read, "{notes:?}"); + assert_eq!(names(&d), ["Live", "Texture"]); + assert_eq!(kinds(&d.layers[1]), ["group[1]"]); + replacement_is_cyan(&d, 1, 0); + assert!(notes.iter().any(|n| n.contains("very large images") && n.contains("they came in as the page's art")), "{notes:?}"); + // Past the bytes the samples may take, too. + let (_, notes, read) = + crate::import::ai::with_image_limits(20, 1 << 31, 1, || crate::layered_ai(&private(&art), pdf_two_squares(), vec![], false, false, 1.0)); + assert!(read && notes.iter().any(|n| n.contains("very large images")), "{notes:?}"); + let (d, notes) = crate::import::ai::with_image_limits(20, 15, 512 << 20, || crate::ai_alone(&private(&art))).unwrap(); + assert_eq!(names(&d), ["Live", "Texture"]); + assert!(d.layers[1].children().unwrap().is_empty()); + assert!(notes.iter().any(|n| n == "an image too large to read from the editing data was left out"), "{notes:?}"); } /// A `.ai` whose layers hold a placed PDF and no text object: its PDF part draws the placed file, @@ -1322,7 +1384,9 @@ fn an_ai_file_that_comes_in_as_its_pdf_part_keeps_the_swatches_of_its_palette() let mut part = pdf_part(10.0, 86.0); let gold = vectorcraft_color::Paint::solid(vectorcraft_color::Color::cmyk(0.0, 0.2, 0.9, 0.1)); part.swatches.push(vectorcraft_color::swatch::Swatch { name: "Gold ink".into(), paint: gold, global: true, spot: true }); - let (d, warnings, read) = crate::layered_ai(&private(&art), part, vec![], false, true, 1.0); + // An image past 2^25 pixels isn't read here, and the page has no art of its own in its box. + let (d, warnings, read) = + crate::import::ai::with_image_limits(1 << 26, 1 << 25, 512 << 20, || crate::layered_ai(&private(&art), part, vec![], false, true, 1.0)); assert!(!read, "the PDF part is used: {warnings:?}"); let swatches: Vec<&str> = d.swatches.iter().map(|s| s.name.as_str()).collect(); assert_eq!(swatches, ["[None]", "White", "Gold ink"], "{warnings:?}"); @@ -4434,6 +4498,56 @@ fn aligned_type_round_a_circle_runs_along_its_path_as_the_file_lays_it_out() { } } +/// Type aligned to a circle by its ascender, center or descender, in the default face at 12 pt: the +/// file's baselines lie inside the path by the top of the face's em box (its `BASE` table's +/// `ideo` -170 plus one em), half the height of its H's top (656 units) or the bottom of its +/// bounding box (-295), and laid out, the type's baselines lie where the file has them. +#[test] +fn aligned_type_lies_as_far_from_its_path_as_the_file_lays_it_out() { + let k = 40.0 * 0.552_284_75; + let points = [ + (90.0, 50.0), + (90.0, 50.0 - k), + (50.0 + k, 10.0), + (50.0, 10.0), + (50.0 - k, 10.0), + (10.0, 50.0 - k), + (10.0, 50.0), + (10.0, 50.0 + k), + (50.0 - k, 90.0), + (50.0, 90.0), + (50.0 + k, 90.0), + (90.0, 50.0 + k), + (90.0, 50.0), + ]; + let all: Vec<(f64, f64)> = points.iter().map(|(x, y)| canvas(*x, *y)).collect(); + let segment = |i: usize| (0..4).map(|j| format!("{} {}", all[i * 3 + j].0, all[i * 3 + j].1)).collect::>().join(" "); + for (key, align, inside) in [ + (0, vectorcraft_doc::PathAlign::Ascender, 0.83 * 12.0), + (2, vectorcraft_doc::PathAlign::Center, 0.328 * 12.0), + (1, vectorcraft_doc::PathAlign::Descender, -0.295 * 12.0), + ] { + let frame = format!( + "<< /0 << /0 [ 0 0 ] /1 << /0 [ {} ] >> /2 << /0 2 /6 [ 2.0 5.99 ] /11 << /2 {key} >> >> >> >>", + (0..4).map(segment).collect::>().join(" ") + ); + let radius = 40.0 - inside; + let doc = document_of(&[frame], &[round_story_with("abcdefgh", 5.0, radius, radius / 48.0, &format!("/0 << /0 [ 0 {inside:.3} ] >>"))]); + let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &text_object(0))), &doc); + let r = import(&eps(&page_square(10, 10), &art)).unwrap(); + let texts = text_of(&r.document.layers[1]); + assert_eq!(texts.len(), 1, "{align:?}: {:?}", r.warnings); + assert_eq!(texts[0].path_align, align); + let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), texts[0]); + assert_eq!(layout.glyphs.len(), 8); + // The four cubics stray up to 0.011 from the circle. + for g in &layout.glyphs { + let (x, y) = (g.origin.x + g.angle.cos() * g.advance * 0.5 - 50.0, g.origin.y + g.angle.sin() * g.advance * 0.5 - 50.0); + assert!((x.hypot(y) - radius).abs() < 0.02, "{align:?}: {} from the center, want {radius}", x.hypot(y)); + } + } +} + #[test] fn threaded_area_type_flows_through_its_frames() { let frames = [ diff --git a/crates/eps/src/import/text.rs b/crates/eps/src/import/text.rs index 7b0df91f9..d0b68bbf5 100644 --- a/crates/eps/src/import/text.rs +++ b/crates/eps/src/import/text.rs @@ -168,9 +168,13 @@ impl Interp<'_> { ResourceForAll => self.resource_for_all()?, Show | AShow | WidthShow | AWidthShow | XShow | YShow | XYShow | KShow => { let (s, spacing) = self.show_operands(op)?; - match self.type3_font() { - Some(font) => self.show_type3(&font, &s, &spacing)?, - None => self.show(&s)?, + if let Some(glyphs) = self.composite_glyphs(&s) { + self.show_composite(&glyphs, &spacing)?; + } else { + match self.type3_font() { + Some(font) => self.show_type3(&font, &s, &spacing)?, + None => self.show(&s)?, + } } } CShow => { @@ -273,6 +277,13 @@ impl Interp<'_> { /// How far `s` moves the current point (user space). fn width_of(&mut self, s: &[u8]) -> Res { + if let Some(glyphs) = self.composite_glyphs(s) { + let mut w = Vec2::ZERO; + for g in &glyphs { + w += self.composite_glyph(g, Point::new(w.x, w.y), Draw::No)?; + } + return Ok(w); + } let Some(font) = self.type3_font() else { return Ok(self.set_type(s, Point::ZERO)?.2) }; let mut w = Vec2::ZERO; for &code in s { @@ -287,19 +298,7 @@ impl Interp<'_> { for (i, &code) in s.iter().enumerate() { let at = self.user_point()?; let w = self.type3_glyph(font, Glyph::Code(code), at, true)?; - let d = match spacing { - Spacing::Plain | Spacing::Kern(_) => w, - Spacing::Add(a, c) => w + *a + c.filter(|(ch, _)| *ch == i64::from(code)).map_or(Vec2::ZERO, |(_, v)| v), - Spacing::Widths(v, op) => { - let n = |k: usize| v.get(k).copied(); - match op { - Op::XShow => n(i).map(|x| Vec2::new(x, 0.0)), - Op::YShow => n(i).map(|y| Vec2::new(0.0, y)), - _ => n(2 * i).zip(n(2 * i + 1)).map(|(x, y)| Vec2::new(x, y)), - } - .unwrap_or(w) - } - }; + let d = spacing.advance(i, i64::from(code), w); self.g.cur = Some(self.xf() * (at + d)); if let (Spacing::Kern(p), Some(next)) = (spacing, s.get(i + 1)) { self.push(Obj::Int(i64::from(code)))?; @@ -315,10 +314,15 @@ impl Interp<'_> { /// Glyph `glyph` of Type 3 font `font` drawn by its `BuildGlyph` (or `BuildChar`) procedure at /// user point `at` (only measured unless `paint`) → its width (user space). fn type3_glyph(&mut self, font: &DictRef, glyph: Glyph, at: Point, paint: bool) -> Res { + self.type3_glyph_in(font, glyph, at, paint, Affine::IDENTITY) + } + + /// [`Self::type3_glyph`] for a descendant of a composite font whose matrix is `outer`. + fn type3_glyph_in(&mut self, font: &DictRef, glyph: Glyph, at: Point, paint: bool, outer: Affine) -> Res { let (fm, build_glyph, build_char, encoding) = { let f = font.borrow(); let get = |k: &str| f.get(&Key::name(k)).cloned(); - let fm = get("FontMatrix").and_then(|o| o.items().and_then(|i| matrix_of(&i.borrow()))).unwrap_or(Affine::scale(0.001)); + let fm = outer * font_matrix(&f, 0.001); (fm, get("BuildGlyph"), get("BuildChar"), get("Encoding")) }; let code_of = |name: &str| -> Option { @@ -402,11 +406,16 @@ impl Interp<'_> { }; let f = font.borrow(); let name = f.get(&Key::name("FontName")).and_then(Obj::text).map_or_else(|| DEFAULT_FONT.to_string(), |n| n.to_string()); - let m = f.get(&Key::name("FontMatrix")).and_then(|o| o.items().and_then(|i| matrix_of(&i.borrow()))).unwrap_or(Affine::scale(0.001)); - // Type 1 glyphs are in 1000-unit space; a Type 42 font's glyph space is one unit an em - // (its FontMatrix, usually the identity, is already the em's). - let units = if f.get(&Key::name("FontType")).and_then(Obj::as_num) == Some(42.0) { 1.0 } else { 1000.0 }; - Ok((name, m * Affine::scale(units))) + // A composite font's glyphs are its descendants', in their glyph space under its matrix + // (PLRM 3rd ed., §5.10 "Composite Fonts"): the first descendant stands for them all. + if font_type(&f) == Some(0.0) { + let first = f.get(&Key::name("FDepVector")).and_then(|v| v.items()?.get(0)); + if let Some(Obj::Dict(d)) = first { + let d = d.borrow(); + return Ok((name, font_matrix(&f, 1.0) * font_matrix(&d, 0.001) * Affine::scale(units(&d)))); + } + } + Ok((name, font_matrix(&f, 0.001) * Affine::scale(units(&f)))) } /// `s` (read as Latin-1) set in the current font at user point `at`: point type placed there, @@ -451,6 +460,14 @@ impl Interp<'_> { /// `charpath`: the glyph outlines of `s` added to the path. fn char_path(&mut self, s: &[u8]) -> Res { + if let Some(glyphs) = self.composite_glyphs(s) { + for g in &glyphs { + let at = self.user_point()?; + let w = self.composite_glyph(g, at, Draw::Path)?; + self.g.cur = Some(self.xf() * (at + w)); + } + return Ok(()); + } let at = self.user_point()?; let (t, layout, adv) = self.set_type(s, at)?; for g in &layout.glyphs { @@ -484,6 +501,240 @@ enum Glyph { Name(Rc), } +impl Spacing { + /// How far glyph `i` (character code `code`, width `w`) moves the current point. + fn advance(&self, i: usize, code: i64, w: Vec2) -> Vec2 { + match self { + Self::Plain | Self::Kern(_) => w, + Self::Add(a, c) => w + *a + c.filter(|(ch, _)| *ch == code).map_or(Vec2::ZERO, |(_, v)| v), + Self::Widths(v, op) => { + let n = |k: usize| v.get(k).copied(); + match op { + Op::XShow => n(i).map(|x| Vec2::new(x, 0.0)), + Op::YShow => n(i).map(|y| Vec2::new(0.0, y)), + _ => n(2 * i).zip(n(2 * i + 1)).map(|(x, y)| Vec2::new(x, y)), + } + .unwrap_or(w) + } + } + } +} + +/// A character of a composite font: the descendant font that draws it, its code there, and the +/// composite font's matrix (over the descendant's own). +struct Mapped { + font: DictRef, + /// The byte that picked the descendant (the font number). + pick: u8, + code: u8, + outer: Affine, +} + +/// What drawing a glyph does with it. +#[derive(Clone, Copy, PartialEq)] +enum Draw { + /// Fills it (`show`). + Fill, + /// Adds its outline to the current path (`charpath`). + Path, + /// Only measures it (`stringwidth`). + No, +} + +/// Font `f`'s `FontType`. +fn font_type(f: &Dict) -> Option { + f.get(&Key::name("FontType")).and_then(Obj::as_num) +} + +/// Font `f`'s matrix, `default` times the identity when it has none. +fn font_matrix(f: &Dict, default: f64) -> Affine { + f.get(&Key::name("FontMatrix")).and_then(|o| o.items().and_then(|i| matrix_of(&i.borrow()))).unwrap_or(Affine::scale(default)) +} + +/// Font `f`'s glyph units in its matrix's space: Type 1 glyphs are in 1000-unit space; a Type 42 +/// font's glyph space is one unit an em (its FontMatrix, usually the identity, is already the +/// em's). +fn units(f: &Dict) -> f64 { + if font_type(f) == Some(42.0) { 1.0 } else { 1000.0 } +} + +impl Interp<'_> { + /// The characters of `s` in the current font when it is a composite font with the 8/8 mapping + /// (`FMapType` 2: a byte picking the descendant through `Encoding` and `FDepVector`, then the + /// code in it: PLRM 3rd ed., §5.10.1 "Type 0 Font Dictionaries"); `None` for other fonts and + /// mappings, which are set as type in the font of the composite font's name. + fn composite_glyphs(&self, s: &[u8]) -> Option> { + let font = self.g.font.clone()?; + let f = font.borrow(); + if font_type(&f) != Some(0.0) || f.get(&Key::name("FMapType")).and_then(Obj::as_num) != Some(2.0) { + return None; + } + let (encoding, fonts) = (f.get(&Key::name("Encoding"))?.items()?.clone(), f.get(&Key::name("FDepVector"))?.items()?.clone()); + let (encoding, fonts) = (encoding.borrow(), fonts.borrow()); + let outer = font_matrix(&f, 1.0); + let mut out = Vec::with_capacity(s.len() / 2); + for &[pick, code] in s.as_chunks::<2>().0 { + let index = encoding.get(usize::from(pick)).and_then(Obj::as_num).filter(|i| *i >= 0.0 && i.fract() == 0.0); + // A character whose descendant is missing (or not a base font) shows nothing. + if let Some(Obj::Dict(d)) = index.and_then(|i| fonts.get(i as usize)) + && font_type(&d.borrow()) != Some(0.0) + { + out.push(Mapped { font: d.clone(), pick, code, outer }); + } + } + Some(out) + } + + /// The characters of a composite font shown at the current point, spaced as `spacing` says. + fn show_composite(&mut self, glyphs: &[Mapped], spacing: &Spacing) -> Res { + let start = self.out.drawn.len(); + let mut text = String::new(); + for (i, g) in glyphs.iter().enumerate() { + let at = self.user_point()?; + let w = self.composite_glyph(g, at, Draw::Fill)?; + // `widthshow` and `awidthshow` match a composite font's character by its font number + // times 256 plus its code (PLRM 3rd ed., `widthshow` in chapter 8). + let d = spacing.advance(i, i64::from(g.pick) * 256 + i64::from(g.code), w); + self.g.cur = Some(self.xf() * (at + d)); + let c = code_char(&g.font.borrow(), g.code); + if c != '\u{2022}' { + text.push(c); + } + } + if text.trim().is_empty() + && let Some(name) = self.g.font.as_ref().and_then(|f| f.borrow().get(&Key::name("FontName")).and_then(Obj::text)) + { + text = name.to_string(); + } + self.group_glyphs(start, &text); + Ok(()) + } + + /// Character `g` of a composite font at user point `at`, drawn by its descendant: a Type 3 + /// font's procedure, a Type 42 font's TrueType outline, else as type in the font of the + /// descendant's name (the character its glyph name stands for) → its width (user space). + fn composite_glyph(&mut self, g: &Mapped, at: Point, paint: Draw) -> Res { + let builds = { + let f = g.font.borrow(); + font_type(&f) == Some(3.0) && (f.contains_key(&Key::name("BuildGlyph")) || f.contains_key(&Key::name("BuildChar"))) + }; + if builds { + return self.type3_glyph_in(&g.font, Glyph::Code(g.code), at, paint == Draw::Fill, g.outer); + } + let fm = g.outer * font_matrix(&g.font.borrow(), 1.0); + if let Some((outline, advance)) = self.type42_glyph(&g.font, g.code)? { + // Measuring a glyph reads its outline too, so `stringwidth` in a loop pays for it. + self.spend((outline.elements().len() as u64).saturating_add(16))?; + if paint != Draw::No { + let mut p = outline; + p.apply_affine(self.xf() * Affine::translate(at.to_vec2()) * fm); + if paint == Draw::Fill { + self.fill_path(p, vectorcraft_geom::FillRule::NonZero)?; + } else { + if self.g.path.elements().len().saturating_add(p.elements().len()) > super::graphics::MAX_PATH { + return Err(PsError::Limit("a path has too many points")); + } + self.g.path.extend(p); + } + } + return Ok(fm * Point::new(advance, 0.0) - fm * Point::ZERO); + } + // Set as type in the descendant's font, under the composite font's matrix. + let c = code_char(&g.font.borrow(), g.code); + let Ok(byte) = u8::try_from(u32::from(c)) else { return Ok(Vec2::ZERO) }; + // Scaling the descendant copies its dictionary for every character: charged by its size, + // so a huge font dictionary shown in a loop runs out of the budget. + let entries = g.font.borrow().len(); + self.spend(entries as u64)?; + let saved = self.g.font.replace(transformed(&g.font, g.outer)); + let r = match paint { + Draw::Fill => { + self.g.cur = Some(self.xf() * at); + self.show(&[byte]).and_then(|()| self.user_point()).map(|p| p - at) + } + Draw::Path => { + self.g.cur = Some(self.xf() * at); + self.char_path(&[byte]).and_then(|()| self.user_point()).map(|p| p - at) + } + Draw::No => self.set_type(&[byte], at).map(|t| t.2), + }; + self.g.font = saved; + r + } + + /// Glyph `code` of Type 42 font `font` from the TrueType font it carries: its outline and + /// width in its glyph space (ems), or `None` when it isn't a Type 42 font with one. + fn type42_glyph(&mut self, font: &DictRef, code: u8) -> Res> { + let (sfnts, gid) = { + let f = font.borrow(); + if font_type(&f) != Some(42.0) { + return Ok(None); + } + let Some(Obj::Array { items: sfnts, .. }) = f.get(&Key::name("sfnts")).cloned() else { return Ok(None) }; + let name = glyph_name(&f, code); + let strings = f.get(&Key::name("CharStrings")).and_then(|c| match c { + Obj::Dict(d) => Some(d.clone()), + _ => None, + }); + let gid = strings + .and_then(|d| { + let d = d.borrow(); + d.get(&Key::name(&name)).or_else(|| d.get(&Key::name(".notdef"))).and_then(Obj::as_num) + }) + .filter(|g| (0.0..=f64::from(u16::MAX)).contains(g)) + .map_or(0, |g| g as u32); + (sfnts, gid) + }; + // The least recently used font goes first: a hit moves to the end. + let data = match self.type42.iter().position(|(k, _)| k.same(&sfnts)) { + Some(i) => { + let hit = self.type42.remove(i); + let data = hit.1.clone(); + self.type42.push(hit); + data + } + None => { + // Joining the strings visits each element and copies its bytes, whether or not + // the font can then be read: charged first, as other bulk copies are. + let (elements, bytes) = super::type42::font_size(&sfnts.borrow()); + self.spend_bytes(bytes)?; + self.spend((elements / 16) as u64)?; + // Fonts whose data can't be read are kept too, so they are copied only once. + let data = super::type42::font_data(&sfnts.borrow()).map(Rc::new); + if let Some(d) = &data + && self.type42_counted.insert(sfnts.clone()) + { + self.alloc(d.len())?; + } + if self.type42.len() >= MAX_TYPE42 { + self.type42.remove(0); + } + self.type42.push((sfnts, data.clone())); + data + } + }; + let Some(data) = data else { return Ok(None) }; + Ok(super::type42::glyph(&data, gid)) + } +} + +/// Most Type 42 fonts whose TrueType data is kept between glyphs. +const MAX_TYPE42: usize = 16; + +/// The character code `code` of font `f` stands for: its glyph name's, or the code read as +/// Latin-1 when the font names no glyph for it (a font found by name, whose program isn't run). +fn code_char(f: &Dict, code: u8) -> char { + match &*glyph_name(f, code) { + ".notdef" => char::from(code), + name => glyph_char(name), + } +} + +/// The glyph name font `f`'s `Encoding` gives code `code` (`.notdef` without one). +fn glyph_name(f: &Dict, code: u8) -> Rc { + f.get(&Key::name("Encoding")).and_then(|e| e.items()?.get(usize::from(code))).and_then(|o| o.text()).unwrap_or_else(|| Rc::from(".notdef")) +} + /// A font scaled or transformed by `m` (`scalefont`, `makefont`). fn transformed(font: &DictRef, m: Affine) -> DictRef { let mut d = font.borrow().clone(); diff --git a/crates/eps/src/import/type42.rs b/crates/eps/src/import/type42.rs new file mode 100644 index 000000000..1aac00b00 --- /dev/null +++ b/crates/eps/src/import/type42.rs @@ -0,0 +1,89 @@ +//! Type 42 fonts' glyphs drawn from the TrueType font they carry, for the type a composite font +//! shows: its glyphs are picked by glyph index, often in a subset font without a character map, +//! so they can't be set again as type in an installed font. The font is the `sfnts` strings +//! joined, each string of odd length ending in a padding byte that isn't part of it (Adobe +//! Technical Note #5012, "The Type 42 Font Format Specification", on the `sfnts` array); a glyph +//! name maps to its glyph index through `CharStrings`. + +use skrifa::instance::{LocationRef, Size}; +use skrifa::outline::{DrawSettings, OutlinePen}; +use skrifa::{FontRef, GlyphId, MetadataProvider}; +use vectorcraft_geom::BezPath; + +use super::obj::Obj; + +/// Most bytes of TrueType data a Type 42 font may carry. +pub(super) const MAX_FONT: usize = 32 << 20; + +/// The TrueType font of a Type 42 font's `sfnts` strings, or `None` when an element isn't a +/// string or the font would be larger than [`MAX_FONT`]. +pub(super) fn font_data(sfnts: &[Obj]) -> Option> { + let mut data = Vec::new(); + for s in sfnts { + let Obj::Str(s) = s else { return None }; + let s = s.borrow(); + let len = s.len() - s.len() % 2; + if data.len() + len > MAX_FONT { + return None; + } + data.extend_from_slice(s.get(..len).unwrap_or_default()); + } + Some(data) +} + +/// What [`font_data`] visits for `sfnts`: the elements up to the first that isn't a string, and +/// the bytes of those strings (at most [`MAX_FONT`] and one string more). +pub(super) fn font_size(sfnts: &[Obj]) -> (usize, usize) { + let mut bytes = 0usize; + for (i, s) in sfnts.iter().enumerate() { + let Obj::Str(s) = s else { return (i + 1, bytes) }; + bytes = bytes.saturating_add(s.borrow().len()); + if bytes > MAX_FONT { + return (i + 1, bytes.min(2 * MAX_FONT)); + } + } + (sfnts.len(), bytes) +} + +/// Glyph `gid` of TrueType font `data`: its outline and advance width, in ems (y up), or `None` +/// when the font can't be read. +pub(super) fn glyph(data: &[u8], gid: u32) -> Option<(BezPath, f64)> { + let font = FontRef::new(data).ok()?; + let upem = f64::from(font.metrics(Size::unscaled(), LocationRef::default()).units_per_em); + if upem <= 0.0 { + return None; + } + let id = GlyphId::new(gid); + let mut pen = Pen(BezPath::new(), 1.0 / upem); + if gid != 0 + && let Some(g) = font.outline_glyphs().get(id) + { + // A glyph that can't be drawn draws nothing; its width still moves the next one. + let _ = g.draw(DrawSettings::unhinted(Size::unscaled(), LocationRef::default()), &mut pen); + } + let advance = font.glyph_metrics(Size::unscaled(), LocationRef::default()).advance_width(id).map_or(0.0, f64::from) / upem; + Some((pen.0, if advance.is_finite() { advance } else { 0.0 })) +} + +/// A pen drawing an outline scaled from font units to ems. +struct Pen(BezPath, f64); + +impl OutlinePen for Pen { + fn move_to(&mut self, x: f32, y: f32) { + self.0.move_to((f64::from(x) * self.1, f64::from(y) * self.1)); + } + fn line_to(&mut self, x: f32, y: f32) { + self.0.line_to((f64::from(x) * self.1, f64::from(y) * self.1)); + } + fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) { + let s = self.1; + self.0.quad_to((f64::from(cx0) * s, f64::from(cy0) * s), (f64::from(x) * s, f64::from(y) * s)); + } + fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) { + let s = self.1; + self.0.curve_to((f64::from(cx0) * s, f64::from(cy0) * s), (f64::from(cx1) * s, f64::from(cy1) * s), (f64::from(x) * s, f64::from(y) * s)); + } + fn close(&mut self) { + self.0.close_path(); + } +} diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs index de2c9b384..c5c1c4c26 100644 --- a/crates/eps/src/legacy_ai.rs +++ b/crates/eps/src/legacy_ai.rs @@ -65,8 +65,8 @@ use std::sync::Arc; use vectorcraft_color::cms::Cms; use vectorcraft_color::{BlendMode, Color, Gradient, GradientKind, GradientPaint, Paint}; use vectorcraft_doc::{ - AppearanceItem, AreaOptions, CharPosition, CharStyle, Document, FillLayer, ImageBlob, Justify, LAYER_COLORS, LayerColor, Node, NodeKind, - ParaDirection, ParaStyle, PathAlign, PathEffect, StrokeLayer, TextKind, TextObject, + AppearanceItem, AreaOptions, CharPosition, CharStyle, CompositeFont, Document, FillLayer, ImageBlob, Justify, LAYER_COLORS, LayerColor, Node, + NodeKind, ParaDirection, ParaStyle, PathAlign, PathEffect, StrokeLayer, TextKind, TextObject, }; use vectorcraft_effects::stroke; use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Rect, Shape, SubPath, Vec2}; @@ -180,6 +180,8 @@ pub fn export_legacy_ai(doc: &Document, o: &LegacyAiOptions) -> Result 0.0 && board.height() > 0.0) { return Err("the document has no artboard".into()); } + // Type in the document's composite fonts is drawn in them, whether or not an app has the document open. + let _lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&doc.composite_fonts); // The document is y down; art space y up from the artboard's left edge, its top on a whole point. let (width, height) = (board.width(), board.height()); let top = if (height - height.round()).abs() < 1e-6 { height.round() } else { height.ceil() }; diff --git a/crates/eps/src/legacy_ai/encoding.rs b/crates/eps/src/legacy_ai/encoding.rs index c2346cbb9..8ea787f8a 100644 --- a/crates/eps/src/legacy_ai/encoding.rs +++ b/crates/eps/src/legacy_ai/encoding.rs @@ -1,7 +1,7 @@ use super::writer::Writer; use super::{ - Affine, AreaOptions, BezPath, CharPosition, CharStyle, Gradient, GradientKind, Paint, ParaStyle, PathAlign, PathEffect, StrokeLayer, TextKind, - TextObject, Write, latin, ps, stroke, + Affine, AreaOptions, BezPath, CharPosition, CharStyle, CompositeFont, Document, Gradient, GradientKind, Paint, ParaStyle, PathAlign, PathEffect, + StrokeLayer, TextKind, TextObject, Write, latin, ps, stroke, }; /// A gradient definition: `%AI5_BeginGradient: (name)`, `(name) type stops Bd`, `[`, the stops @@ -78,10 +78,11 @@ pub(super) fn latin_string(name: &str) -> String { ps::bytes(&bytes) } -/// Can type `t` be written as text: 8-bit text can hold it ([`holds_as_text`]) and no character -/// is rotated ([`rotated`])? -pub(super) fn writable(t: &TextObject) -> bool { - holds_as_text(t) && !rotated(t) +/// Can type `t` of document `doc` be written as text: 8-bit text can hold it ([`holds_as_text`]), +/// no character is rotated ([`rotated`]) and no run is set in one of the document's composite +/// fonts, which a reader can't find by its name? +pub(super) fn writable(doc: &Document, t: &TextObject) -> bool { + holds_as_text(t) && !rotated(t) && !t.runs.iter().any(|r| CompositeFont::named(&doc.composite_fonts, &r.style.font_family).is_some()) } /// Has type `t` visible characters with Character Rotation? A legacy `.ai` text object's character diff --git a/crates/eps/src/legacy_ai/tests.rs b/crates/eps/src/legacy_ai/tests.rs index b34c127a7..c3ca85c08 100644 --- a/crates/eps/src/legacy_ai/tests.rs +++ b/crates/eps/src/legacy_ai/tests.rs @@ -136,9 +136,9 @@ fn as_printed(doc: &Document, o: &LegacyAiOptions) -> Document { nodes.extend(layout.glyphs.iter().filter_map(|g| g.placed_colour(t.xf)).map(Arc::new)); Node::group(vectorcraft_doc::NodeId(0), nodes) } - fn plain(nodes: &mut Vec>, o: &LegacyAiOptions, images: &mut std::collections::BTreeMap) { + fn plain(doc: &Document, nodes: &mut Vec>, o: &LegacyAiOptions, images: &mut std::collections::BTreeMap) { nodes.retain(|n| match &n.kind { - NodeKind::Text(t) => o.outline_text || !writable(t), + NodeKind::Text(t) => o.outline_text || !writable(doc, t), NodeKind::Layer { printable, .. } => *printable, _ => true, }); @@ -160,13 +160,13 @@ fn as_printed(doc: &Document, o: &LegacyAiOptions) -> Document { } } if let Some(c) = n.children_mut() { - plain(c, o, images); + plain(doc, c, o, images); } } } let mut d = doc.clone(); let mut images = std::mem::take(&mut d.images); - plain(&mut d.layers, o, &mut images); + plain(doc, &mut d.layers, o, &mut images); d.images = images; d } diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs index ae8fddc70..cf601e939 100644 --- a/crates/eps/src/legacy_ai/writer.rs +++ b/crates/eps/src/legacy_ai/writer.rs @@ -673,7 +673,7 @@ impl<'a> Writer<'a> { let resolved = vectorcraft_text::FontDb::global().font_heights_resolved(t); let t = &*resolved; if !self.outline_text { - if writable(t) { + if writable(self.doc, t) { return self.text_object(t); } if !holds_as_text(t) { diff --git a/crates/eps/src/lib.rs b/crates/eps/src/lib.rs index bf515de66..e8fd85764 100644 --- a/crates/eps/src/lib.rs +++ b/crates/eps/src/lib.rs @@ -224,6 +224,8 @@ pub fn export(doc: &Document, opts: &EpsOptions, preview: Option<&Raster>, thumb if ![r.x0, r.y0, r.x1, r.y1, opts.origin.x, opts.origin.y].iter().all(|v| v.is_finite()) { return Err("the export region is not finite".into()); } + // Type in the document's composite fonts is drawn in them, whether or not an app has the document open. + let _lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&doc.composite_fonts); let baked = vectorcraft_effects::bake_document(doc); let doc = baked.as_ref().unwrap_or(doc); let page = scene::Scene::new(doc, opts).run(); diff --git a/crates/pathops/README.md b/crates/pathops/README.md index 2a3d36c18..08380830b 100644 --- a/crates/pathops/README.md +++ b/crates/pathops/README.md @@ -87,6 +87,27 @@ pub fn split_into_grid(rect: Rect, rows: usize, cols: usize, gutter: f64) -> Vec `simplify` fits least-squares cubics with fixed end tangents, then reparameterizes with Newton steps and splits at the point of worst error. Any anchor that turns more than the corner threshold (30° by default) stays a corner. +## Limits + +Every boolean, Pathfinder operation, region map and normalization goes through one sweep. The `try_` functions +(`try_boolean`, `try_normalize`, `try_unite_all`, …) refuse input it can't sweep with a [`PathOpsError`](src/lib.rs) +rather than answering with part of the art missing. The others have no error to return: `boolean`, `normalize` and +`area` answer an empty path or 0, and `unite_all` leaves a refused input out of the union, or answers an empty path +when the inputs together are refused. + +- `NonFinite`: a coordinate is NaN or infinite. +- `Degenerate`: the coordinates are so large that the sweep's tolerance would pass one unit (`MAX_SWEEP_EPS`), so + features thinner than it would collapse. A panic inside the sweep on rare near-degenerate input is caught and + reported as `Degenerate` too (on native builds; on the web a panic aborts). +- `OpenPath`: the sweep could not form closed contours from what it was given (open subpaths are closed before it). +- `TooComplex`: more than [`MAX_SEGMENT_PAIRS`](src/budget.rs) pairs of segments have overlapping bounds. They + are counted exactly before the sweep, in O(n log n). This bounds what the sweep is given, not how many times the + segments cross: a compact path that crosses itself many times can stay under it and still take seconds. + +Results are refitted within the precision (`DEFAULT_PRECISION`, unless `try_boolean`, `unite_all_with`, +`try_unite_all_with` or `pathfinder_with` is given one) and contours thinner than it on average are dropped as slivers +(see Booleans above). + ## Caveats - linesweeper is still in beta. When two identical curves overlap, the sweep can leave a tiny lens between them; the sliver filter removes it. diff --git a/crates/pdf/src/export/entry.rs b/crates/pdf/src/export/entry.rs index 92607d5c5..c64d4a6f5 100644 --- a/crates/pdf/src/export/entry.rs +++ b/crates/pdf/src/export/entry.rs @@ -9,6 +9,8 @@ pub fn export(doc: &Document, opts: &PdfOptions) -> Result, PdfError> { /// Like [`export`], also returning warnings: options accepted but not applied yet (see /// [`crate::PdfSettings::warnings`]) and features approximated or dropped. pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result { + // Type in the document's composite fonts is drawn in them, whether or not an app has the document open. + let _lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&doc.composite_fonts); write::guard(|| leaving_out_undecodable(doc, |left_out| export_with_report_inner(doc, opts, left_out))) } diff --git a/crates/pdf/src/tests/realtext.rs b/crates/pdf/src/tests/realtext.rs index e866ba5cb..375b319b3 100644 --- a/crates/pdf/src/tests/realtext.rs +++ b/crates/pdf/src/tests/realtext.rs @@ -470,6 +470,21 @@ fn numbers_in_threaded_frames_are_written_as_text_in_each_frame() { assert_markers_in_place(&d, &back, &drawn.iter().map(String::as_str).collect::>(), 0.1); } +/// The numbers of list paragraphs at a deeper level are written as text where the canvas draws +/// them, 18 pt further in, each level with its own count. +#[test] +fn numbers_at_a_deeper_list_level_are_written_as_text_where_the_canvas_draws_them() { + let number = |level| Some(ParaList::numbered(NumberStyle::Arabic).at_level(level)); + let d = doc(vec![listed("first\nsecond\nthird\nfourth", &[number(1), number(2), number(2), number(1)])]); + let origins = marker_origins(&d); + let x = |m: usize| origins.get(m).and_then(|o| o.first()).map_or(f64::NAN, |p| p.x); + assert!((x(1) - x(0) - 18.0).abs() < 1e-6 && (x(3) - x(0)).abs() < 1e-6, "{origins:?}"); + let back = import_as(&pdf(&d, false).bytes, TextAs::Text); + let all = texts(&back).concat(); + assert!(before(&all, "1.", "first") && before(&all, "2.", "third") && all.matches("2.").count() == 2, "{all}"); + assert_markers_in_place(&d, &back, &["1.", "1.", "2.", "2."], 0.1); +} + /// A number set in oldstyle figures (the first character's OpenType features) is written as its /// digits, though the character map gives the digits other glyphs. The reopened type has no /// OpenType features and sets lining figures, so only where each number starts is compared. diff --git a/crates/plugins/src/host.rs b/crates/plugins/src/host.rs index f47d8e69b..017df5a80 100644 --- a/crates/plugins/src/host.rs +++ b/crates/plugins/src/host.rs @@ -1,20 +1,28 @@ //! What a plug-in may import from the host ([`IMPORTS`]), from plug-in API level 2 on: //! `vc_progress` reports how far a run has got, `vc_cancelled` tells it the user or a host limit -//! asked it to stop, and `vc_log` writes a log line. A module may import them only when its -//! manifest gives `api` 2 and lists the `progress` permission, and the host checks each import when -//! the module is loaded ([`check_imports`], [`check_granted`]): one it doesn't offer, one with -//! another signature and one the manifest doesn't allow are refused by name. +//! asked it to stop, and `vc_log` writes a log line (the `progress` permission); `vc_settings_*` +//! read and write its settings (`settings`) and `vc_cache_*` its cache (`cache`, [`crate::storage`]). +//! A module may import one only when its manifest gives `api` 2 and lists the import's permission, +//! and the host checks each import when the module is loaded ([`check_imports`], [`check_granted`]): +//! one it doesn't offer, one with another signature and one the manifest doesn't allow are refused +//! by name. //! //! Each call's arguments are checked: progress is clamped and passed on only when it changes (with //! a known total, to another tenth of a percent; past [`MAX_PROGRESS_REPORTS`], once per //! [`PROGRESS_STRIDE`] changes), log text must lie inside the module's memory and be UTF-8, and //! each line ([`MAX_LOG_LINE`]) and each run ([`MAX_LOG_LINES`], [`MAX_LOG_BYTES`], -//! [`MAX_LOG_READ`]) are capped. Nothing a module passes can panic the host. +//! [`MAX_LOG_READ`]) are capped. Keys and values must lie inside the module's memory, keys must be +//! [`crate::storage::valid_name`]s, values are capped, and so are each run's storage calls +//! ([`MAX_STORAGE_CALLS`]) and the bytes they move ([`MAX_STORAGE_BYTES`]). Nothing a module passes +//! can panic the host. + +use std::sync::Arc; use wasmi::{Caller, Engine, Extern, ExternType, Linker, Module, StoreLimits, ValType}; use crate::manifest::Manifest; use crate::permissions::{self, PROGRESS_API, Permission}; +use crate::storage::{self, Meter, Storage, StorageError}; use crate::{Error, Result}; /// The module a plug-in imports host functions from (the name a Rust or C toolchain gives @@ -60,16 +68,51 @@ pub const LOG_NOT_UTF8: i32 = -2; /// What `vc_log` returns: the level is not 0 to 3. pub const LOG_BAD_LEVEL: i32 = -3; -/// One host import: its name and its type. +/// What a storage import that writes or removes returns when it did. +pub const STORAGE_DONE: i32 = 0; +/// What a storage import returns: the key or entry has no value. +pub const STORAGE_NOT_FOUND: i32 = -1; +/// What a storage import returns: a key, value or buffer doesn't lie inside the module's memory. +pub const STORAGE_OUTSIDE_MEMORY: i32 = -2; +/// What a storage import returns: the key is not a [`crate::storage::valid_name`]. +pub const STORAGE_BAD_NAME: i32 = -3; +/// What a storage import returns: the write would pass a cap ([`crate::storage`]); nothing changed. +pub const STORAGE_FULL: i32 = -4; +/// What a storage import returns: there is nowhere to keep anything (no plug-in store, as on the +/// web, or a module that isn't installed). +pub const STORAGE_UNAVAILABLE: i32 = -5; +/// What a storage import returns: reading or writing the file failed. +pub const STORAGE_FAILED: i32 = -6; +/// What a storage import returns: the run used up its storage calls or bytes +/// ([`MAX_STORAGE_CALLS`], [`MAX_STORAGE_BYTES`]). +pub const STORAGE_BUDGET: i32 = -7; + +/// Most storage calls one run makes; later ones return [`STORAGE_BUDGET`]. +pub const MAX_STORAGE_CALLS: usize = 10_000; + +/// Most bytes one run's storage calls read and write, all together, counting the whole settings +/// file each settings call reads and writes and each cache entry read or written; a call that would +/// pass it returns [`STORAGE_BUDGET`]. +pub const MAX_STORAGE_BYTES: usize = 1 << 30; + +/// One host import: its name, its type and the permission it needs. pub struct Import { pub name: &'static str, params: &'static [ValType], results: &'static [ValType], /// Its type as WebAssembly text writes it. pub signature: &'static str, + pub permission: Permission, } -/// The host imports of API level 2, in module [`MODULE`]: +const I32: ValType = ValType::I32; + +/// An import that takes `params` i32 arguments and returns an i32. +const fn storage_import(name: &'static str, params: &'static [ValType], signature: &'static str, permission: Permission) -> Import { + Import { name, params, results: &[I32], signature, permission } +} + +/// The host imports of API level 2, in module [`MODULE`]. With `progress`: /// /// - `vc_progress(done: i64, total: i64)`: `done` of `total` steps are finished (`total` 0 or less: /// not known). Negative values count as 0 and `done` is clamped to `total`; @@ -77,17 +120,71 @@ pub struct Import { /// - `vc_log(level: i32, ptr: i32, len: i32) -> i32`: writes the UTF-8 text at `ptr` as a log line /// of `level` (0 error, 1 warning, 2 information, 3 debug) → [`LOG_WRITTEN`], [`LOG_DROPPED`], /// [`LOG_OUTSIDE_MEMORY`], [`LOG_NOT_UTF8`] or [`LOG_BAD_LEVEL`]. -pub const IMPORTS: [Import; 3] = [ - Import { name: "vc_progress", params: &[ValType::I64, ValType::I64], results: &[], signature: "(param i64 i64)" }, - Import { name: "vc_cancelled", params: &[], results: &[ValType::I32], signature: "(result i32)" }, +/// +/// With `settings`, for the key of `key_len` bytes at `key`: +/// +/// - `vc_settings_get(key, key_len, out, out_cap) -> i32`: the value's length, its bytes copied to +/// `out` when they fit in `out_cap` (nothing is copied otherwise); +/// - `vc_settings_set(key, key_len, value, value_len) -> i32`: sets it → [`STORAGE_DONE`]; +/// - `vc_settings_delete(key, key_len) -> i32`: removes it → [`STORAGE_DONE`]. +/// +/// With `cache`, the same for the entry named at `name`: `vc_cache_read`, `vc_cache_write` and +/// `vc_cache_clear(name, name_len)`, which removes the entry, or every entry when `name_len` is 0. +/// +/// A storage call that fails returns [`STORAGE_NOT_FOUND`], [`STORAGE_OUTSIDE_MEMORY`], +/// [`STORAGE_BAD_NAME`], [`STORAGE_FULL`], [`STORAGE_UNAVAILABLE`], [`STORAGE_FAILED`] or +/// [`STORAGE_BUDGET`]. +pub const IMPORTS: [Import; 9] = [ + Import { + name: "vc_progress", + params: &[ValType::I64, ValType::I64], + results: &[], + signature: "(param i64 i64)", + permission: Permission::Progress, + }, + Import { name: "vc_cancelled", params: &[], results: &[I32], signature: "(result i32)", permission: Permission::Progress }, Import { name: "vc_log", - params: &[ValType::I32, ValType::I32, ValType::I32], - results: &[ValType::I32], + params: &[I32, I32, I32], + results: &[I32], signature: "(param i32 i32 i32) (result i32)", + permission: Permission::Progress, }, + storage_import("vc_settings_get", &[I32, I32, I32, I32], "(param i32 i32 i32 i32) (result i32)", Permission::Settings), + storage_import("vc_settings_set", &[I32, I32, I32, I32], "(param i32 i32 i32 i32) (result i32)", Permission::Settings), + storage_import("vc_settings_delete", &[I32, I32], "(param i32 i32) (result i32)", Permission::Settings), + storage_import("vc_cache_read", &[I32, I32, I32, I32], "(param i32 i32 i32 i32) (result i32)", Permission::Cache), + storage_import("vc_cache_write", &[I32, I32, I32, I32], "(param i32 i32 i32 i32) (result i32)", Permission::Cache), + storage_import("vc_cache_clear", &[I32, I32], "(param i32 i32) (result i32)", Permission::Cache), ]; +/// Which of a plug-in's stores a storage import reads or writes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Area { + Settings, + Cache, +} + +impl Area { + /// The largest value one key or entry holds. + fn max_value(self) -> usize { + match self { + Area::Settings => storage::MAX_SETTING_BYTES, + Area::Cache => storage::MAX_CACHE_ENTRY, + } + } +} + +/// What a storage import returns for `e`. +fn storage_code(e: &StorageError) -> i32 { + match e { + StorageError::Full => STORAGE_FULL, + StorageError::Unavailable => STORAGE_UNAVAILABLE, + StorageError::Io(_) => STORAGE_FAILED, + StorageError::Budget => STORAGE_BUDGET, + } +} + /// A log line's level. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum LogLevel { @@ -189,6 +286,11 @@ pub(crate) struct Run { /// Bytes of log text written (with their escapes) and read. log_bytes: usize, log_read: usize, + /// Where the plug-in keeps its settings and cache (none while the module is being loaded), and + /// how many storage calls the run made and bytes they moved. + storage: Option>, + storage_calls: usize, + storage_bytes: usize, pub(crate) report: Report, } @@ -204,10 +306,111 @@ impl Run { skipped: 0, log_bytes: 0, log_read: 0, + storage: None, + storage_calls: 0, + storage_bytes: 0, report: Report::default(), } } + /// The run, with the plug-in's settings and cache kept in `storage`. + pub(crate) fn with_storage(mut self, storage: Option>) -> Self { + self.storage = storage; + self + } + + /// Makes one storage call with `call`, on the plug-in's storage and a [`Meter`] holding the + /// run's storage bytes left, and counts the call and the bytes it read and wrote → the code for + /// a run that has used up its calls or has no storage, else what `call` returns. + fn metered(&mut self, call: impl FnOnce(&dyn Storage, &mut Meter) -> std::result::Result) -> std::result::Result { + if self.storage_calls >= MAX_STORAGE_CALLS { + return Err(STORAGE_BUDGET); + } + self.storage_calls += 1; + let Some(store) = self.storage.clone() else { return Err(STORAGE_UNAVAILABLE) }; + let mut meter = Meter::new(MAX_STORAGE_BYTES.saturating_sub(self.storage_bytes) as u64); + let done = call(store.as_ref(), &mut meter); + // What the call read before it failed counts too. + self.storage_bytes = self.storage_bytes.saturating_add(usize::try_from(meter.used()).unwrap_or(usize::MAX)); + done.map_err(|e| storage_code(&e)) + } + + /// The key or entry name of `len` bytes at `ptr` in `memory`, checked, or the code a storage + /// import returns for it. + fn storage_name(memory: &[u8], ptr: usize, len: usize) -> std::result::Result { + let bytes = ptr.checked_add(len).and_then(|end| memory.get(ptr..end)).ok_or(STORAGE_OUTSIDE_MEMORY)?; + let name = std::str::from_utf8(bytes).ok().filter(|n| storage::valid_name(n)).ok_or(STORAGE_BAD_NAME)?; + Ok(name.to_string()) + } + + /// `vc_settings_get` and `vc_cache_read`: copies the value of the key at `name` into the + /// `out_cap` bytes at `out` when it fits → its length. + fn storage_read(&mut self, area: Area, memory: &mut [u8], name: (usize, usize), out: (usize, usize)) -> i32 { + let key = match Self::storage_name(memory, name.0, name.1) { + Ok(key) => key, + Err(code) => return code, + }; + if out.1 > 0 && out.0.checked_add(out.1).is_none_or(|end| end > memory.len()) { + return STORAGE_OUTSIDE_MEMORY; + } + // The storage counts what it read whether or not the value fits the buffer. + let got = self.metered(|store, meter| match area { + Area::Settings => store.setting(&key, meter), + Area::Cache => store.cache_entry(&key, meter), + }); + let value = match got { + Ok(Some(value)) => value, + Ok(None) => return STORAGE_NOT_FOUND, + Err(code) => return code, + }; + let Ok(len) = i32::try_from(value.len()) else { return STORAGE_FAILED }; + if value.len() <= out.1 + && let Some(dest) = memory.get_mut(out.0..out.0 + value.len()) + { + dest.copy_from_slice(&value); + } + len + } + + /// `vc_settings_set` and `vc_cache_write`: sets the key at `name` to the `value` bytes. + fn storage_write(&mut self, area: Area, memory: &[u8], name: (usize, usize), value: (usize, usize)) -> i32 { + let key = match Self::storage_name(memory, name.0, name.1) { + Ok(key) => key, + Err(code) => return code, + }; + let Some(bytes) = value.0.checked_add(value.1).and_then(|end| memory.get(value.0..end)) else { return STORAGE_OUTSIDE_MEMORY }; + if bytes.len() > area.max_value() { + return STORAGE_FULL; + } + let written = self.metered(|store, meter| match area { + Area::Settings => store.set_setting(&key, bytes, meter), + Area::Cache => store.set_cache_entry(&key, bytes, meter), + }); + written.map_or_else(|code| code, |()| STORAGE_DONE) + } + + /// `vc_settings_delete` and `vc_cache_clear`: removes the key at `name`, or with `vc_cache_clear` + /// and a `name` of no bytes, every cache entry. + fn storage_remove(&mut self, area: Area, memory: &[u8], name: (usize, usize)) -> i32 { + let key = match (area, name.1) { + (Area::Cache, 0) => None, + _ => match Self::storage_name(memory, name.0, name.1) { + Ok(key) => Some(key), + Err(code) => return code, + }, + }; + let removed = self.metered(|store, meter| match (area, key) { + (Area::Settings, Some(key)) => store.delete_setting(&key, meter), + (Area::Cache, Some(key)) => store.remove_cache_entry(&key), + (_, None) => store.clear_cache().map(|_| true), + }); + match removed { + Ok(true) => STORAGE_DONE, + Ok(false) => STORAGE_NOT_FOUND, + Err(code) => code, + } + } + fn progress(&mut self, done: i64, total: i64) { let total = u64::try_from(total).unwrap_or(0); let done = u64::try_from(done).unwrap_or(0); @@ -286,55 +489,89 @@ pub(crate) struct Host { pub(crate) run: Run, } -/// A linker that offers the host imports when `granted`, and no import otherwise. -pub(crate) fn linker(engine: &Engine, granted: bool) -> Linker { +/// The module's memory and the store's state, for an import that reads or writes the memory. +fn memory_and_host<'a>(caller: &'a mut Caller<'_, Host>) -> Option<(&'a mut [u8], &'a mut Host)> { + let Some(Extern::Memory(memory)) = caller.get_export("memory") else { return None }; + Some(memory.data_and_store_mut(caller)) +} + +/// An i32 argument as the unsigned offset or length it stands for. +fn offset(v: i32) -> usize { + v as u32 as usize +} + +/// A linker that offers the host imports whose permission is in `granted`, and no other import. +pub(crate) fn linker(engine: &Engine, granted: &[Permission]) -> Linker { let mut linker = Linker::::new(engine); - if granted { - // Each name is defined once, so none of these fails. + // Each name is defined once, so none of these fails. + if granted.contains(&Permission::Progress) { let _ = linker.func_wrap(MODULE, "vc_progress", |mut caller: Caller<'_, Host>, done: i64, total: i64| { caller.data_mut().run.progress(done, total); }); let _ = linker.func_wrap(MODULE, "vc_cancelled", |caller: Caller<'_, Host>| -> i32 { caller.data().run.cancelled() }); let _ = linker.func_wrap(MODULE, "vc_log", |mut caller: Caller<'_, Host>, level: i32, ptr: i32, len: i32| -> i32 { - let Some(Extern::Memory(memory)) = caller.get_export("memory") else { return LOG_OUTSIDE_MEMORY }; - let (data, host) = memory.data_and_store_mut(&mut caller); - host.run.log(level, data, ptr as u32 as usize, len as u32 as usize) + let Some((data, host)) = memory_and_host(&mut caller) else { return LOG_OUTSIDE_MEMORY }; + host.run.log(level, data, offset(ptr), offset(len)) + }); + } + for (area, permission, get, set, remove) in [ + (Area::Settings, Permission::Settings, "vc_settings_get", "vc_settings_set", "vc_settings_delete"), + (Area::Cache, Permission::Cache, "vc_cache_read", "vc_cache_write", "vc_cache_clear"), + ] { + if !granted.contains(&permission) { + continue; + } + let _ = linker.func_wrap(MODULE, get, move |mut caller: Caller<'_, Host>, key: i32, key_len: i32, out: i32, out_cap: i32| -> i32 { + let Some((data, host)) = memory_and_host(&mut caller) else { return STORAGE_OUTSIDE_MEMORY }; + host.run.storage_read(area, data, (offset(key), offset(key_len)), (offset(out), offset(out_cap))) + }); + let _ = linker.func_wrap(MODULE, set, move |mut caller: Caller<'_, Host>, key: i32, key_len: i32, value: i32, value_len: i32| -> i32 { + let Some((data, host)) = memory_and_host(&mut caller) else { return STORAGE_OUTSIDE_MEMORY }; + host.run.storage_write(area, data, (offset(key), offset(key_len)), (offset(value), offset(value_len))) + }); + let _ = linker.func_wrap(MODULE, remove, move |mut caller: Caller<'_, Host>, key: i32, key_len: i32| -> i32 { + let Some((data, host)) = memory_and_host(&mut caller) else { return STORAGE_OUTSIDE_MEMORY }; + host.run.storage_remove(area, data, (offset(key), offset(key_len))) }); } linker } -/// Checks that every import of `module` is a host import with the host's signature → their names. -/// Any other import is refused, and the error names it. -pub(crate) fn check_imports(module: &Module) -> Result> { - let mut names = Vec::new(); +/// Checks that every import of `module` is a host import with the host's signature → them. Any +/// other import is refused, and the error names it. +pub(crate) fn check_imports(module: &Module) -> Result> { + let mut found = Vec::new(); for imp in module.imports() { let offered = if imp.module() == MODULE { IMPORTS.iter().find(|i| i.name == imp.name()) } else { None }; let (from, name) = (permissions::display_text(imp.module(), 64), permissions::display_text(imp.name(), 64)); let Some(host) = offered else { return Err(Error::Abi(format!( - "the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network" + "the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only the `env.vc_*` functions that docs/plugins.md lists (plug-in API level 2), and no WASI, files or network" ))); }; match imp.ty() { - ExternType::Func(f) if f.params() == host.params && f.results() == host.results => names.push(host.name), + ExternType::Func(f) if f.params() == host.params && f.results() == host.results => found.push(host), _ => { let signature = host.signature; return Err(Error::Abi(format!("the module imports `env.{name}` with another type than the host's: it is {signature}"))); } } } - Ok(names) + Ok(found) } -/// Checks that `manifest` allows the host imports `imports`: `api` 2 and the `progress` -/// permission. The error names the first import it doesn't allow. -pub(crate) fn check_granted(manifest: &Manifest, imports: &[&str]) -> Result<()> { - match imports.first() { - Some(name) if manifest.api < PROGRESS_API || !manifest.permissions().contains(&Permission::Progress) => Err(Error::Abi(format!( - "the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress`" - ))), - _ => Ok(()), +/// Checks that `manifest` allows the host imports `imports`: `api` 2 and each import's permission. +/// The error names the first import it doesn't allow. +pub(crate) fn check_granted(manifest: &Manifest, imports: &[&Import]) -> Result<()> { + let granted = manifest.permissions(); + match imports.iter().find(|i| manifest.api < PROGRESS_API || !granted.contains(&i.permission)) { + Some(import) => { + let (name, permission) = (import.name, import.permission.name()); + Err(Error::Abi(format!( + "the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `{permission}`" + ))) + } + None => Ok(()), } } diff --git a/crates/plugins/src/lib.rs b/crates/plugins/src/lib.rs index fd2375be6..4ac06db48 100644 --- a/crates/plugins/src/lib.rs +++ b/crates/plugins/src/lib.rs @@ -3,14 +3,16 @@ //! A plug-in is a WebAssembly module that implements the VectorCraft plug-in ABI (v1, documented //! in `docs/plugins.md`). It runs in [`wasmi`], a pure-Rust interpreter, with: //! -//! - **no host imports but the three of API level 2** ([`host`]: progress, Cancel and log lines, -//! with the `progress` permission): no WASI, file system, network, clock or randomness. A module -//! that imports anything else is rejected; +//! - **no host imports but those of API level 2** ([`host`]: progress, Cancel and log lines with +//! the `progress` permission, and settings and a cache of its own with the `settings` and `cache` +//! permissions, [`storage`]): no WASI, file system, network, clock or randomness. A module that +//! imports anything else is rejected; //! - an **instruction budget** (fuel) per call, metered in slices so a wall-clock deadline is //! also enforced (native builds), so infinite loops end with an error. A filter may ask for a //! larger one ([`Budget::Heavy`]), which the host lets the user allow before installing it; //! - a **linear-memory cap**, a **recursion-depth cap** and wasmi's strict module limits; -//! - a **fresh instance per run**, so no state leaks between calls. +//! - a **fresh instance per run**, so no state leaks between calls; what a plug-in keeps is only +//! what it writes to its settings and cache. //! //! Every failure (malformed module, trap, exhausted budget, bad output) is an [`Error`]; nothing //! a module does can panic or hang the host. @@ -36,6 +38,7 @@ pub mod permissions; pub mod registry; mod runtime; pub mod safe_mode; +pub mod storage; pub mod wat; pub use dialog::ParamUi; diff --git a/crates/plugins/src/manifest.rs b/crates/plugins/src/manifest.rs index 36b4b78c4..da66ae820 100644 --- a/crates/plugins/src/manifest.rs +++ b/crates/plugins/src/manifest.rs @@ -308,10 +308,11 @@ impl Manifest { return Err(Error::Manifest(format!("`api` is {}; this host implements plug-in API levels 1 to {API_LEVEL}", self.api))); } if let Some(names) = &self.permissions - && permissions::validate(self.kind, names)?.contains(&Permission::Progress) + && let Some(host) = permissions::validate(self.kind, names)?.into_iter().find(|p| permissions::HOST.contains(p)) && self.api < PROGRESS_API { - return Err(Error::Manifest(format!("permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API}"))); + let permission = host.name(); + return Err(Error::Manifest(format!("permission `{permission}` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API}"))); } self.validate_extensions()?; match (self.budget, self.kind) { @@ -658,6 +659,9 @@ mod tests { assert_eq!(serde_json::to_value(&level2).unwrap()["api"], json!(2)); let plain2 = Manifest::parse(br#"{"id":"a","name":"x","kind":"filter","api":2}"#).unwrap(); assert!(!plain2.permissions().contains(&Permission::Progress), "never implied"); + let storing = Manifest::parse(br#"{"id":"a","name":"x","kind":"filter","api":2,"permissions":["cache","settings"]}"#).unwrap(); + assert_eq!(storing.permissions(), [Permission::Settings, Permission::Cache]); + assert!(!plain2.permissions().iter().any(|p| permissions::HOST.contains(p)), "never implied"); for (bad, says) in [ (r#"{"id":"a","name":"x","kind":"filter","permissions":["doc.read","net.fetch"]}"#, "unknown permission `net.fetch`"), (r#"{"id":"a","name":"x","kind":"filter","permissions":["ui.panel"]}"#, "reserved"), @@ -665,6 +669,8 @@ mod tests { (r#"{"id":"a","name":"x","kind":"filter","permissions":"doc.read"}"#, "manifest"), (r#"{"id":"a","name":"x","kind":"filter","permissions":["progress"]}"#, "`progress` needs plug-in API level 2"), (r#"{"id":"a","name":"x","kind":"filter","api":1,"permissions":["progress"]}"#, "`progress` needs plug-in API level 2"), + (r#"{"id":"a","name":"x","kind":"filter","permissions":["settings"]}"#, "`settings` needs plug-in API level 2"), + (r#"{"id":"a","name":"x","kind":"filter","api":1,"permissions":["doc.read","cache"]}"#, "`cache` needs plug-in API level 2"), (r#"{"id":"a","name":"x","kind":"filter","api":3}"#, "`api` is 3"), (r#"{"id":"a","name":"x","kind":"filter","api":0}"#, "`api` is 0"), (r#"{"id":"a","name":"x","kind":"filter","api":-1}"#, "manifest"), diff --git a/crates/plugins/src/permissions.rs b/crates/plugins/src/permissions.rs index 41ab1569a..4587c801d 100644 --- a/crates/plugins/src/permissions.rs +++ b/crates/plugins/src/permissions.rs @@ -5,8 +5,9 @@ //! `doc.read`, `doc.write`, `state` and `progress` are the names of the plug-in capability model in //! storytold/photocraft#662; the other names are VectorCraft's own. A manifest without //! `permissions` gets the ones its kind implies ([`Permission::implied`]): what every plug-in of -//! that kind could do before manifests listed them. `progress`, which lets a plug-in use the host -//! imports of API level 2 ([`crate::host`]), is never implied: a manifest lists it. +//! that kind could do before manifests listed them. `progress`, `settings` and `cache`, which let a +//! plug-in use the host imports of API level 2 ([`crate::host`]), are never implied: a manifest +//! lists them. use serde::{Deserialize, Serialize}; @@ -16,8 +17,8 @@ use crate::{Error, Kind, Result}; /// this. pub const API_LEVEL: u32 = 2; -/// The API level that adds the host imports ([`crate::host::IMPORTS`]) and the `progress` -/// permission they need. +/// The API level that adds the host imports ([`crate::host::IMPORTS`]) and the `progress`, +/// `settings` and `cache` permissions they need. pub const PROGRESS_API: u32 = 2; /// Most permissions a manifest may list. @@ -56,11 +57,29 @@ pub enum Permission { /// host imports of API level 2 ([`crate::host::IMPORTS`]). #[serde(rename = "progress")] Progress, + /// Keep settings of its own between runs and sessions ([`crate::storage`]). + #[serde(rename = "settings")] + Settings, + /// Keep a cache of its own, which the user can clear ([`crate::storage`]). + #[serde(rename = "cache")] + Cache, } /// Every permission this host grants, in the order prompts list them. -pub const ALL: [Permission; 6] = - [Permission::DocRead, Permission::DocWrite, Permission::State, Permission::EffectRender, Permission::ExportWrite, Permission::Progress]; +pub const ALL: [Permission; 8] = [ + Permission::DocRead, + Permission::DocWrite, + Permission::State, + Permission::EffectRender, + Permission::ExportWrite, + Permission::Progress, + Permission::Settings, + Permission::Cache, +]; + +/// The permissions of the host imports, which any kind may list and none implies; each needs plug-in +/// API level [`PROGRESS_API`]. +pub const HOST: [Permission; 3] = [Permission::Progress, Permission::Settings, Permission::Cache]; impl Permission { /// Its name in a manifest and in `plugin.install`'s `grant`. @@ -72,6 +91,8 @@ impl Permission { Permission::EffectRender => "effect.render", Permission::ExportWrite => "export.write", Permission::Progress => "progress", + Permission::Settings => "settings", + Permission::Cache => "cache", } } @@ -89,17 +110,19 @@ impl Permission { Permission::EffectRender => "Change how the objects its effect is applied to are drawn", Permission::ExportWrite => "Write the document as a file in its own format", Permission::Progress => "Show its progress, see Cancel and write log lines", + Permission::Settings => "Keep settings of its own", + Permission::Cache => "Keep a cache of its own, which you can clear", } } - /// May a plug-in of `kind` list it: one its kind implies, or `progress`, which any kind may - /// list. + /// May a plug-in of `kind` list it: one its kind implies, or one of the host imports' ([`HOST`]), + /// which any kind may list. pub fn applies(self, kind: Kind) -> bool { - self == Permission::Progress || Permission::implied(kind).contains(&self) + HOST.contains(&self) || Permission::implied(kind).contains(&self) } /// The permissions a manifest of `kind` without `permissions` gets. A manifest may also list - /// `progress` ([`Permission::applies`]). + /// the host imports' ([`HOST`], [`Permission::applies`]). pub fn implied(kind: Kind) -> &'static [Permission] { match kind { Kind::Filter => &[Permission::DocRead, Permission::DocWrite, Permission::State], @@ -247,10 +270,22 @@ mod tests { assert!(validate(Kind::Effect, &list(&["doc.read", "effect.render", "progress"])).is_ok()); assert!(validate(Kind::Exporter, &list(&["progress", "export.write", "doc.read"])).is_ok()); assert!([Kind::Filter, Kind::Effect, Kind::Exporter].iter().all(|k| !Permission::implied(*k).contains(&Permission::Progress))); + // So may `settings` and `cache`, which no kind implies either. + assert_eq!( + validate(Kind::Effect, &list(&["cache", "settings", "doc.read", "effect.render"])).unwrap(), + [Permission::DocRead, Permission::EffectRender, Permission::Settings, Permission::Cache] + ); + assert!(validate(Kind::Exporter, &list(&["settings", "export.write", "doc.read"])).is_ok()); + assert_eq!(validate(Kind::Filter, &list(&["cache"])).unwrap(), [Permission::Cache]); + for kind in [Kind::Filter, Kind::Effect, Kind::Exporter] { + assert!(HOST.iter().all(|p| p.applies(kind) && !Permission::implied(kind).contains(p))); + } for (kind, names, says) in [ (Kind::Filter, &["doc.read", "clipboard"][..], "unknown permission `clipboard`"), (Kind::Filter, &["ui.panel"], "`ui.panel` is reserved"), (Kind::Filter, &["progress", "progress"], "`progress` is listed twice"), + (Kind::Filter, &["cache", "cache"], "`cache` is listed twice"), + (Kind::Filter, &["storage"], "unknown permission `storage`"), (Kind::Filter, &["export.write"], "doesn't apply to a plug-in of kind `filter`"), (Kind::Exporter, &["doc.read", "export.write", "doc.read"], "`doc.read` is listed twice"), (Kind::Effect, &["doc.read"], "needs the permission `effect.render`"), diff --git a/crates/plugins/src/registry.rs b/crates/plugins/src/registry.rs index ce1161827..d2e0f2302 100644 --- a/crates/plugins/src/registry.rs +++ b/crates/plugins/src/registry.rs @@ -10,12 +10,30 @@ use std::sync::{Arc, Mutex, RwLock}; #[cfg(not(target_arch = "wasm32"))] use crate::Error; use crate::permissions::Grant; +use crate::storage::Storage; use crate::{Limits, Plugin, Result}; static PLUGINS: RwLock>> = RwLock::new(BTreeMap::new()); static REVISION: AtomicU64 = AtomicU64::new(0); /// What the user approved, by plug-in id. static GRANTS: RwLock> = RwLock::new(BTreeMap::new()); +/// Where each plug-in keeps its settings and cache, by plug-in id. +static STORAGE: RwLock>> = RwLock::new(BTreeMap::new()); + +/// Keeps the settings and cache of plug-in `id` in `storage` from now on (`None`: nowhere, so its +/// storage calls fail as unavailable). A run of the plug-in reaches only its own id's storage. +pub fn set_storage(id: &str, storage: Option>) { + let mut all = STORAGE.write().unwrap_or_else(|e| e.into_inner()); + match storage { + Some(s) => all.insert(id.to_string(), s), + None => all.remove(id), + }; +} + +/// Where plug-in `id` keeps its settings and cache, if anywhere. +pub fn storage(id: &str) -> Option> { + STORAGE.read().unwrap_or_else(|e| e.into_inner()).get(id).cloned() +} /// Records what the user approved for a plug-in, replacing an earlier grant for its id. pub fn grant(g: Grant) { @@ -47,9 +65,12 @@ pub fn approved(plugin: &Plugin) -> bool { /// Installs `plugin`, replacing one with the same id. pub fn install(mut plugin: Plugin) -> Arc { + // The revision moves while the write lock is held, so a cache that reads the new revision and + // then the plug-ins finds the new plug-in among them. + let mut plugins = PLUGINS.write().unwrap_or_else(|e| e.into_inner()); plugin.set_serial(REVISION.fetch_add(1, Ordering::Relaxed) + 1); let p = Arc::new(plugin); - PLUGINS.write().unwrap_or_else(|e| e.into_inner()).insert(p.id().to_string(), p.clone()); + plugins.insert(p.id().to_string(), p.clone()); p } diff --git a/crates/plugins/src/runtime.rs b/crates/plugins/src/runtime.rs index 7059aed15..d37a9159d 100644 --- a/crates/plugins/src/runtime.rs +++ b/crates/plugins/src/runtime.rs @@ -154,8 +154,9 @@ impl Plugin { let hash = crate::permissions::sha256_hex(bytes); let mut p = Plugin { manifest: Manifest::placeholder(), engine, module, limits, size: bytes.len(), hash, source: None, serial: 0 }; let deadline = Deadline::new(p.limits.wall_time_ms); - // The manifest isn't read yet: every host import is linked, and what it reports is dropped. - let mut live = p.instantiate(Run::new("", false, false, deadline), true, &mut Report::default())?; + // The manifest isn't read yet: every host import is linked, what it reports is dropped and + // there is no storage to reach. + let mut live = p.instantiate(Run::new("", false, false, deadline), &crate::permissions::ALL, &mut Report::default())?; let version: TypedFunc<(), i32> = live.func("vc_abi_version")?; let v = p.call(&mut live, &version, (), p.limits.fuel_base, &deadline)?; if v != ABI_VERSION { @@ -215,9 +216,9 @@ impl Plugin { self.serial = serial; } - /// A new instance for `run`, with the host imports linked when `granted`. When the module's - /// start function fails, what it reported through the host imports goes to `report`. - fn instantiate(&self, run: Run, granted: bool, report: &mut Report) -> Result { + /// A new instance for `run`, with the host imports of the permissions `granted` linked. When the + /// module's start function fails, what it reported through the host imports goes to `report`. + fn instantiate(&self, run: Run, granted: &[Permission], report: &mut Report) -> Result { let limits = StoreLimitsBuilder::new().memory_size(self.limits.max_memory_bytes).memories(1).tables(4).table_elements(100_000).instances(1).build(); let mut store = Store::new(&self.engine, Host { limits, run }); @@ -287,7 +288,8 @@ impl Plugin { } /// [`Plugin::run_limited`], and what the run reported through the host imports: its last - /// progress report and its log lines, which also go to the app's log under its id. Inside a + /// progress report and its log lines, which also go to the app's log under its id. Its storage + /// imports reach the storage of its id ([`crate::registry::storage`]). Inside a /// [`vectorcraft_doc::cancel::scope`] its progress goes to the scope's sink (a live effect's /// doesn't) and `vc_cancelled` sees the scope's Cancel. pub fn run_reporting(&self, input: &[u8], params: &[u8], limits: &Limits) -> (Result>, Report) { @@ -306,8 +308,8 @@ impl Plugin { total.ok_or_else(|| Error::Limit("memory budget (the selection is too large to hand over)".into()))?; let deadline = Deadline::new(limits.wall_time_ms); let m = &self.manifest; - let run = Run::new(&m.id, m.kind != Kind::Effect, true, deadline); - let mut live = self.instantiate(run, m.permissions().contains(&Permission::Progress), report)?; + let run = Run::new(&m.id, m.kind != Kind::Effect, true, deadline).with_storage(crate::registry::storage(&m.id)); + let mut live = self.instantiate(run, &m.permissions(), report)?; let out = self.run_live(&mut live, input, params, limits, &deadline); *report = std::mem::take(&mut live.store.data_mut().run.report); out diff --git a/crates/plugins/src/storage.rs b/crates/plugins/src/storage.rs new file mode 100644 index 000000000..f3ef2e899 --- /dev/null +++ b/crates/plugins/src/storage.rs @@ -0,0 +1,151 @@ +//! What a plug-in keeps between runs, from plug-in API level 2 on: **settings**, a few named values +//! (the `settings` permission), and a **cache** of named entries the user can clear (the `cache` +//! permission). A plug-in reads and writes them through the host imports ([`crate::host::IMPORTS`]), +//! naming each by a key ([`valid_name`]), never by a path; the host keeps them per plug-in id +//! ([`Storage`], given with [`crate::registry::set_storage`]), so one plug-in can't reach another's. +//! +//! The caps are VectorCraft's own values: [`MAX_SETTINGS`] keys of at most [`MAX_SETTING_BYTES`] +//! each in a settings file of at most [`MAX_SETTINGS_BYTES`], and cache entries of at most +//! [`MAX_CACHE_ENTRY`] bytes, [`MAX_CACHE_BYTES`] and [`MAX_CACHE_ENTRIES`] in all. A write that +//! would pass one fails with [`StorageError::Full`] and changes nothing. + +/// Longest key or cache entry name, in bytes. +pub const MAX_NAME: usize = 64; + +/// Most settings a plug-in keeps. +pub const MAX_SETTINGS: usize = 256; + +/// Largest value of one setting, in bytes. +pub const MAX_SETTING_BYTES: usize = 64 << 10; + +/// Largest settings file, in bytes: the keys and the values in base64, as JSON. +pub const MAX_SETTINGS_BYTES: usize = 1 << 20; + +/// Largest cache entry, in bytes. +pub const MAX_CACHE_ENTRY: usize = 64 << 20; + +/// Most bytes a plug-in's cache holds, all entries together. +pub const MAX_CACHE_BYTES: u64 = 512 << 20; + +/// Most entries a plug-in's cache holds. +pub const MAX_CACHE_ENTRIES: usize = 4096; + +/// Why a storage call failed. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum StorageError { + /// The write would pass a cap; nothing was changed. + Full, + /// There is nowhere to keep anything: no plug-in store is attached, as in the web build. + Unavailable, + /// Reading or writing the file failed, with the system's reason. + Io(String), + /// The call would read or write more bytes than its [`Meter`] has left; nothing was changed. + Budget, +} + +/// The bytes a storage call may still read and write, and those it has. The storage charges each +/// file it reads or writes, at its size, before it does. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Meter { + left: u64, + used: u64, +} + +impl Meter { + /// A meter that lets a call read and write `left` bytes. + pub fn new(left: u64) -> Self { + Meter { left, used: 0 } + } + + /// A meter with no limit, for callers that don't count. + pub fn unlimited() -> Self { + Meter::new(u64::MAX) + } + + /// Counts `bytes` about to be read or written → [`StorageError::Budget`], counting nothing, + /// when that is more than is left. + pub fn take(&mut self, bytes: u64) -> Result<(), StorageError> { + if bytes > self.left { + return Err(StorageError::Budget); + } + self.left -= bytes; + self.used = self.used.saturating_add(bytes); + Ok(()) + } + + /// The bytes counted so far. + pub fn used(&self) -> u64 { + self.used + } +} + +impl std::fmt::Display for StorageError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + StorageError::Full => write!(f, "the plug-in's storage is full"), + StorageError::Unavailable => write!(f, "no plug-in storage is available"), + StorageError::Io(why) => write!(f, "{why}"), + StorageError::Budget => write!(f, "the run has used up its storage bytes"), + } + } +} + +/// Where one plug-in's settings and cache are kept. Every key and name the host passes is a +/// [`valid_name`], and every value is within its cap ([`MAX_SETTING_BYTES`], [`MAX_CACHE_ENTRY`]); +/// the storage enforces the totals. The calls that read or write a file charge `meter` the bytes +/// they read and write ([`Meter::take`]) before they do: a settings call the whole settings file it +/// reads and the whole file it writes, a cache call the entry it reads or writes. +pub trait Storage: Send + Sync { + /// The value of setting `key`, if it has one. + fn setting(&self, key: &str, meter: &mut Meter) -> Result>, StorageError>; + /// Sets `key` to `value`. + fn set_setting(&self, key: &str, value: &[u8], meter: &mut Meter) -> Result<(), StorageError>; + /// Removes setting `key` → whether it had a value. + fn delete_setting(&self, key: &str, meter: &mut Meter) -> Result; + /// The cache entry `name`, if there is one. + fn cache_entry(&self, name: &str, meter: &mut Meter) -> Result>, StorageError>; + /// Writes the cache entry `name`. + fn set_cache_entry(&self, name: &str, value: &[u8], meter: &mut Meter) -> Result<(), StorageError>; + /// Removes the cache entry `name` → whether there was one. + fn remove_cache_entry(&self, name: &str) -> Result; + /// Removes every cache entry → the bytes they held. + fn clear_cache(&self) -> Result; + /// The bytes the cache holds now. + fn cache_bytes(&self) -> u64; +} + +/// Is `name` a key or cache entry name a plug-in may use: 1 to [`MAX_NAME`] characters from +/// `A-Z a-z 0-9 _ -`, and not a name Windows keeps for a device (`CON`, `PRN`, `AUX`, `NUL`, +/// `COM0` to `COM9`, `LPT0` to `LPT9`, in any case)? Keys differ in case: `a` and `A` are two. +pub fn valid_name(name: &str) -> bool { + (1..=MAX_NAME).contains(&name.len()) && name.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-')) && !device_name(name) +} + +/// Is `name` one of the device names Windows keeps? +fn device_name(name: &str) -> bool { + let upper = name.to_ascii_uppercase(); + match upper.as_bytes() { + [a, b, c] => matches!(&[*a, *b, *c], b"CON" | b"PRN" | b"AUX" | b"NUL"), + [a, b, c, d] => matches!(&[*a, *b, *c], b"COM" | b"LPT") && d.is_ascii_digit(), + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn names_are_short_plain_and_never_a_device() { + for ok in ["a", "Z", "zoom-level", "tile_12", &"x".repeat(MAX_NAME), "CONSOLE", "COM10", "LPTX", "con1"] { + assert!(valid_name(ok), "{ok}"); + } + for bad in ["", "..", ".", "a/b", "a\\b", "a.b", "a b", "a\u{0}", "\u{7}", "é", &"x".repeat(MAX_NAME + 1), "CON", "con", "Nul", "aux", "prn"] + { + assert!(!valid_name(bad), "{bad:?}"); + } + for n in 0..10 { + assert!(!valid_name(&format!("COM{n}")) && !valid_name(&format!("lpt{n}"))); + } + } +} diff --git a/crates/plugins/src/wat.rs b/crates/plugins/src/wat.rs index af041414a..f6bd289a6 100644 --- a/crates/plugins/src/wat.rs +++ b/crates/plugins/src/wat.rs @@ -59,6 +59,16 @@ pub const HOST_IMPORTS: &str = r#"(import "env" "vc_progress" (func $vc_progress (import "env" "vc_cancelled" (func $vc_cancelled (result i32))) (import "env" "vc_log" (func $vc_log (param i32 i32 i32) (result i32)))"#; +/// The storage imports of plug-in API level 2 ([`crate::host::IMPORTS`]), as `$vc_settings_get`, +/// `$vc_settings_set`, `$vc_settings_delete`, `$vc_cache_read`, `$vc_cache_write` and +/// `$vc_cache_clear`; a manifest that uses them lists `settings` and `cache`. +pub const STORAGE_IMPORTS: &str = r#"(import "env" "vc_settings_get" (func $vc_settings_get (param i32 i32 i32 i32) (result i32))) + (import "env" "vc_settings_set" (func $vc_settings_set (param i32 i32 i32 i32) (result i32))) + (import "env" "vc_settings_delete" (func $vc_settings_delete (param i32 i32) (result i32))) + (import "env" "vc_cache_read" (func $vc_cache_read (param i32 i32 i32 i32) (result i32))) + (import "env" "vc_cache_write" (func $vc_cache_write (param i32 i32 i32 i32) (result i32))) + (import "env" "vc_cache_clear" (func $vc_cache_clear (param i32 i32) (result i32)))"#; + /// A plug-in of API level 2 (its manifest gives `api` 2 and lists `progress`) that imports the /// host functions ([`HOST_IMPORTS`]) and whose `vc_run` runs `first`, which may call them, then /// returns `output`. diff --git a/crates/plugins/tests/abi.rs b/crates/plugins/tests/abi.rs index f5b78d224..bb32906ea 100644 --- a/crates/plugins/tests/abi.rs +++ b/crates/plugins/tests/abi.rs @@ -6,6 +6,7 @@ use std::time::{Duration, Instant}; use serde_json::{Value, json}; +use vectorcraft_plugins::storage::{Meter, Storage, StorageError}; use vectorcraft_plugins::{Error, HEAVY_FUEL, HEAVY_WALL_TIME_MS, Kind, Limits, Plugin, Report, heavy_terms, registry, wat as tpl}; /// The example Desaturate plug-in, built from `crates/plugins/example`. @@ -356,8 +357,8 @@ fn load_err(bytes: &[u8]) -> String { Plugin::load(bytes, Limits::default()).expect_err("the module must be refused").to_string() } -/// A module may import only the host's three functions, with the host's types, and only when its -/// manifest gives `api` 2 and lists `progress`; the error names the import it refuses. +/// A module may import only the host's functions, with the host's types, and the progress ones only +/// when its manifest gives `api` 2 and lists `progress`; the error names the import it refuses. #[test] fn host_imports_are_checked_when_a_module_is_loaded() { let with = |manifest: &str, import: &str| build(&tpl::with_imports(manifest, import, "", INPUT)); @@ -502,6 +503,206 @@ fn what_a_start_function_reports_before_it_fails_is_kept() { assert_eq!(report.progress, Some((1, 4))); } +/// Settings and a cache kept in memory, for the storage imports. Each call charges its meter the +/// bytes of the value it reads or writes. +#[derive(Default)] +struct Kept { + settings: std::sync::Mutex>>, + cache: std::sync::Mutex>>, +} + +/// The value `key` has in `kept`, once `meter` is charged its bytes. +fn charged( + kept: &std::sync::Mutex>>, + key: &str, + meter: &mut Meter, +) -> Result>, StorageError> { + let value = kept.lock().unwrap().get(key).cloned(); + meter.take(value.as_ref().map_or(0, |v| v.len() as u64))?; + Ok(value) +} + +impl Storage for Kept { + fn setting(&self, key: &str, meter: &mut Meter) -> Result>, StorageError> { + charged(&self.settings, key, meter) + } + fn set_setting(&self, key: &str, value: &[u8], meter: &mut Meter) -> Result<(), StorageError> { + meter.take(value.len() as u64)?; + self.settings.lock().unwrap().insert(key.into(), value.to_vec()); + Ok(()) + } + fn delete_setting(&self, key: &str, _: &mut Meter) -> Result { + Ok(self.settings.lock().unwrap().remove(key).is_some()) + } + fn cache_entry(&self, name: &str, meter: &mut Meter) -> Result>, StorageError> { + charged(&self.cache, name, meter) + } + fn set_cache_entry(&self, name: &str, value: &[u8], meter: &mut Meter) -> Result<(), StorageError> { + meter.take(value.len() as u64)?; + self.cache.lock().unwrap().insert(name.into(), value.to_vec()); + Ok(()) + } + fn remove_cache_entry(&self, name: &str) -> Result { + Ok(self.cache.lock().unwrap().remove(name).is_some()) + } + fn clear_cache(&self) -> Result { + let mut cache = self.cache.lock().unwrap(); + let freed = cache.values().map(|v| v.len() as u64).sum(); + cache.clear(); + Ok(freed) + } + fn cache_bytes(&self) -> u64 { + self.cache.lock().unwrap().values().map(|v| v.len() as u64).sum() + } +} + +/// A filter `id` of API level 2 that lists `settings` and `cache`. +fn storing(id: &str) -> String { + format!(r#"{{"id":"{id}","name":"Storing","version":"1","kind":"filter","api":2,"permissions":["settings","cache"]}}"#) +} + +/// Instructions that trap unless `call` returns `want`. +fn expect(call: &str, want: i32) -> String { + format!("(if (i32.ne {call} (i32.const {want})) (then unreachable))") +} + +/// The module fields of a storage test: the storage imports, keys at 0x2000 ("zoom", "..", "a/b", +/// "CON", a tab, 65 "x"), the value "hello" at 0x2100; 0x3000 is free for an output buffer. +fn storage_data() -> String { + format!(r#"{} (data (i32.const 0x2000) "zoom..a/bCON\09{}") (data (i32.const 0x2100) "hello")"#, tpl::STORAGE_IMPORTS, "x".repeat(65)) +} + +/// Each storage import needs its own permission: a module that imports one its manifest doesn't +/// list is refused, and the error names the import and the permission. +#[test] +fn storage_imports_need_their_own_permission() { + let with = |manifest: &str, import: &str| build(&tpl::with_imports(manifest, import, "", INPUT)); + let get = r#"(import "env" "vc_settings_get" (func (param i32 i32 i32 i32) (result i32)))"#; + let clear = r#"(import "env" "vc_cache_clear" (func (param i32 i32) (result i32)))"#; + let e = load_err(&with(LEVEL2, get)); + assert!( + e.contains("imports `env.vc_settings_get`, which a plug-in may use only at plug-in API level 2 (`api` 2) and with the permission `settings`"), + "{e}" + ); + let settings_only = r#"{"id":"test.settings","name":"S","kind":"filter","api":2,"permissions":["settings"]}"#; + assert!(Plugin::load(&with(settings_only, get), Limits::default()).is_ok()); + let e = load_err(&with(settings_only, clear)); + assert!( + e.contains("imports `env.vc_cache_clear`, which a plug-in may use only at plug-in API level 2 (`api` 2) and with the permission `cache`"), + "{e}" + ); + let wrong = load_err(&with(&storing("test.wrong"), r#"(import "env" "vc_cache_clear" (func (param i32) (result i32)))"#)); + assert!(wrong.contains("imports `env.vc_cache_clear` with another type than the host's: it is (param i32 i32) (result i32)"), "{wrong}"); + assert!(Plugin::load(&build(&tpl::with_imports(&storing("test.all"), tpl::STORAGE_IMPORTS, "", INPUT)), Limits::default()).is_ok()); +} + +/// A plug-in sets, reads and removes its settings and cache entries through the storage imports, +/// by key; a value too large for its buffer returns its length and copies nothing; a key or buffer +/// outside its memory, a key that isn't a plain name and a value over its cap are errors it sees. +#[test] +fn storage_imports_read_and_write_the_plugins_own_storage() { + use vectorcraft_plugins::host::{STORAGE_BAD_NAME, STORAGE_DONE, STORAGE_FULL, STORAGE_NOT_FOUND, STORAGE_OUTSIDE_MEMORY}; + let id = "test.storage.own"; + let kept = std::sync::Arc::new(Kept::default()); + registry::set_storage(id, Some(kept.clone())); + let zoom = "(i32.const 0x2000) (i32.const 4)"; + let first = [ + expect(&format!("(call $vc_settings_get {zoom} (i32.const 0x3000) (i32.const 16))"), STORAGE_NOT_FOUND), + expect(&format!("(call $vc_settings_set {zoom} (i32.const 0x2100) (i32.const 5))"), STORAGE_DONE), + // Too small a buffer: the length, and nothing copied. + expect(&format!("(call $vc_settings_get {zoom} (i32.const 0x3000) (i32.const 4))"), 5), + expect("(i32.load8_u (i32.const 0x3000))", 0), + expect(&format!("(call $vc_settings_get {zoom} (i32.const 0x3000) (i32.const 16))"), 5), + expect("(i32.load (i32.const 0x3000))", i32::from_le_bytes(*b"hell")), + expect(&format!("(call $vc_cache_write {zoom} (i32.const 0x2100) (i32.const 0))"), STORAGE_DONE), + expect(&format!("(call $vc_cache_read {zoom} (i32.const 0x3000) (i32.const 0))"), 0), + expect(&format!("(call $vc_cache_write {zoom} (i32.const 0x2100) (i32.const 3))"), STORAGE_DONE), + // Hostile keys: `..`, `a/b`, `CON`, a tab, 65 bytes, no bytes. + expect("(call $vc_settings_set (i32.const 0x2004) (i32.const 2) (i32.const 0x2100) (i32.const 5))", STORAGE_BAD_NAME), + expect("(call $vc_settings_set (i32.const 0x2006) (i32.const 3) (i32.const 0x2100) (i32.const 5))", STORAGE_BAD_NAME), + expect("(call $vc_cache_write (i32.const 0x2009) (i32.const 3) (i32.const 0x2100) (i32.const 5))", STORAGE_BAD_NAME), + expect("(call $vc_cache_read (i32.const 0x200c) (i32.const 1) (i32.const 0x3000) (i32.const 16))", STORAGE_BAD_NAME), + expect("(call $vc_settings_get (i32.const 0x200d) (i32.const 65) (i32.const 0x3000) (i32.const 16))", STORAGE_BAD_NAME), + expect("(call $vc_settings_delete (i32.const 0x2000) (i32.const 0))", STORAGE_BAD_NAME), + // Outside memory: the key, the value, the buffer. + expect("(call $vc_settings_get (i32.const -1) (i32.const 4) (i32.const 0x3000) (i32.const 16))", STORAGE_OUTSIDE_MEMORY), + expect(&format!("(call $vc_settings_set {zoom} (i32.const 0x2100) (i32.const -1))"), STORAGE_OUTSIDE_MEMORY), + expect(&format!("(call $vc_cache_read {zoom} (i32.mul (memory.size) (i32.const 65536)) (i32.const 1))"), STORAGE_OUTSIDE_MEMORY), + // A setting over its cap changes nothing. + expect( + &format!("(call $vc_settings_set {zoom} (i32.const 0) (i32.const {}))", vectorcraft_plugins::storage::MAX_SETTING_BYTES + 1), + STORAGE_FULL, + ), + expect(&format!("(call $vc_settings_get {zoom} (i32.const 0x3000) (i32.const 16))"), 5), + ] + .concat(); + let p = Plugin::load(&build(&tpl::with_imports(&storing(id), &storage_data(), &first, INPUT)), Limits::default()).unwrap(); + assert_eq!(p.run(INPUT.as_bytes(), b"{}").unwrap(), INPUT.as_bytes(), "every call returned what it should"); + assert_eq!(kept.settings.lock().unwrap().get("zoom"), Some(&b"hello".to_vec())); + assert_eq!(kept.cache.lock().unwrap().get("zoom"), Some(&b"hel".to_vec())); + // Removing: one setting, then a missing one, then the whole cache, then a missing entry. + let removing = [ + expect("(call $vc_settings_delete (i32.const 0x2000) (i32.const 4))", STORAGE_DONE), + expect("(call $vc_settings_delete (i32.const 0x2000) (i32.const 4))", STORAGE_NOT_FOUND), + expect("(call $vc_cache_clear (i32.const 0) (i32.const 0))", STORAGE_DONE), + expect("(call $vc_cache_clear (i32.const 0x2000) (i32.const 4))", STORAGE_NOT_FOUND), + ] + .concat(); + let p = Plugin::load(&build(&tpl::with_imports(&storing(id), &storage_data(), &removing, INPUT)), Limits::default()).unwrap(); + assert_eq!(p.run(INPUT.as_bytes(), b"{}").unwrap(), INPUT.as_bytes(), "every call returned what it should"); + assert!(kept.settings.lock().unwrap().is_empty() && kept.cache.lock().unwrap().is_empty()); + registry::set_storage(id, None); +} + +/// A read counts the value's bytes toward the run's storage bytes even when its buffer is too small +/// and nothing is copied, so reading a large entry into no buffer again and again ends in +/// `STORAGE_BUDGET`. +#[test] +fn a_read_into_too_small_a_buffer_still_counts_its_bytes() { + use vectorcraft_plugins::host::{MAX_STORAGE_BYTES, STORAGE_BUDGET}; + use vectorcraft_plugins::storage::MAX_CACHE_ENTRY; + let id = "test.storage.budget"; + let kept = std::sync::Arc::new(Kept::default()); + kept.cache.lock().unwrap().insert("zoom".into(), vec![0; MAX_CACHE_ENTRY]); + registry::set_storage(id, Some(kept.clone())); + let read = "(call $vc_cache_read (i32.const 0x2000) (i32.const 4) (i32.const 0x3000) (i32.const 0))"; + let fits = MAX_STORAGE_BYTES / MAX_CACHE_ENTRY; + let reading = [vec![expect(read, MAX_CACHE_ENTRY as i32); fits], vec![expect(read, STORAGE_BUDGET)]].concat().concat(); + let p = Plugin::load(&build(&tpl::with_imports(&storing(id), &storage_data(), &reading, INPUT)), Limits::default()).unwrap(); + assert_eq!(p.run(INPUT.as_bytes(), b"{}").unwrap(), INPUT.as_bytes(), "every call returned what it should"); + registry::set_storage(id, None); +} + +/// A plug-in reaches only the storage of its own id, and a plug-in with none (not installed with a +/// plug-in store, as on the web) gets `STORAGE_UNAVAILABLE`. +#[test] +fn a_plugin_reaches_only_its_own_storage() { + use vectorcraft_plugins::host::{STORAGE_DONE, STORAGE_NOT_FOUND, STORAGE_UNAVAILABLE}; + let (mine, theirs) = (std::sync::Arc::new(Kept::default()), std::sync::Arc::new(Kept::default())); + registry::set_storage("test.storage.mine", Some(mine.clone())); + registry::set_storage("test.storage.theirs", Some(theirs.clone())); + theirs.settings.lock().unwrap().insert("zoom".into(), b"theirs".to_vec()); + theirs.cache.lock().unwrap().insert("zoom".into(), b"theirs".to_vec()); + let zoom = "(i32.const 0x2000) (i32.const 4)"; + let looking = [ + expect(&format!("(call $vc_settings_get {zoom} (i32.const 0x3000) (i32.const 16))"), STORAGE_NOT_FOUND), + expect(&format!("(call $vc_cache_read {zoom} (i32.const 0x3000) (i32.const 16))"), STORAGE_NOT_FOUND), + expect("(call $vc_cache_clear (i32.const 0) (i32.const 0))", STORAGE_DONE), + expect(&format!("(call $vc_settings_set {zoom} (i32.const 0x2100) (i32.const 5))"), STORAGE_DONE), + ] + .concat(); + let p = Plugin::load(&build(&tpl::with_imports(&storing("test.storage.mine"), &storage_data(), &looking, INPUT)), Limits::default()).unwrap(); + assert_eq!(p.run(INPUT.as_bytes(), b"{}").unwrap(), INPUT.as_bytes()); + assert_eq!(theirs.settings.lock().unwrap().get("zoom"), Some(&b"theirs".to_vec())); + assert_eq!(theirs.cache_bytes(), 6); + assert_eq!(mine.settings.lock().unwrap().get("zoom"), Some(&b"hello".to_vec())); + let nowhere = expect(&format!("(call $vc_settings_set {zoom} (i32.const 0x2100) (i32.const 5))"), STORAGE_UNAVAILABLE); + let p = Plugin::load(&build(&tpl::with_imports(&storing("test.storage.nowhere"), &storage_data(), &nowhere, INPUT)), Limits::default()).unwrap(); + assert_eq!(p.run(INPUT.as_bytes(), b"{}").unwrap(), INPUT.as_bytes()); + registry::set_storage("test.storage.mine", None); + registry::set_storage("test.storage.theirs", None); +} + /// A heavy filter installs once the user agrees, which loading a folder doesn't ask: it is skipped /// and reported. #[test] diff --git a/crates/render/src/encode/psd.rs b/crates/render/src/encode/psd.rs index f03e73783..777276e69 100644 --- a/crates/render/src/encode/psd.rs +++ b/crates/render/src/encode/psd.rs @@ -222,7 +222,16 @@ impl Record { extra.push(ascii.len() as u8); extra.extend(ascii); extra.resize(extra.len().next_multiple_of(4), 0); - let units: Vec = self.name.encode_utf16().take(255).collect(); + // Cut before the character that would pass 255 units, never inside a surrogate pair. + let mut units: Vec = vec![]; + for c in self.name.chars() { + let mut buf = [0; 2]; + let encoded = c.encode_utf16(&mut buf); + if units.len() + encoded.len() > 255 { + break; + } + units.extend_from_slice(encoded); + } let mut luni = (units.len() as u32).to_be_bytes().to_vec(); luni.extend(units.iter().flat_map(|u| u.to_be_bytes())); block(&mut extra, b"luni", &luni)?; diff --git a/crates/render/src/encode/tests_psd.rs b/crates/render/src/encode/tests_psd.rs index 60f5aae05..e56278f1d 100644 --- a/crates/render/src/encode/tests_psd.rs +++ b/crates/render/src/encode/tests_psd.rs @@ -321,3 +321,12 @@ fn the_profile_and_the_size_limit() { let err = Renderer::new().export_region(&doc, doc.artboards[0].rect, RasterFormat::Psd, &o).unwrap_err(); assert!(err.contains(&MAX_SIDE.to_string()), "{err}"); } + +#[test] +fn long_unicode_name_is_cut_on_a_character_boundary() { + let mut b = DocBuilder::new(100.0, 60.0); + b.layer(&("a".repeat(254) + "😀")); + b.rect(Rect::new(0.0, 0.0, 5.0, 5.0), Color::rgb8(0, 0, 0), |_| {}); + let file = export(&b.build(), &opts(PsdOptions::default())); + assert_eq!(parse(&file).records.last().unwrap().name, "a".repeat(254)); +} diff --git a/crates/render/src/ink.rs b/crates/render/src/ink.rs index dee93e903..10bfe03ce 100644 --- a/crates/render/src/ink.rs +++ b/crates/render/src/ink.rs @@ -563,17 +563,25 @@ mod tests { fn the_ink_table_matches_the_profile() { let cms = cms::active(); let lut = InkLut::build(&cms); - let mut worst = 0.0f32; + let (mut worst, mut worst_dark) = (0.0f32, 0.0f32); // Between the grid points (where interpolation errs most) and on them. for i in 0..=20 { for j in 0..=20 { let inks = [i as f32 / 20.0, j as f32 / 20.0, ((i * 7 + j * 3) % 21) as f32 / 20.0, ((i + j * 5) % 21) as f32 / 20.0]; let (a, b) = (lut.apply(inks), cms.cmyk_to_srgb(inks, false)); - worst = worst.max((0..3).map(|c| (a[c] - b[c]).abs()).fold(0.0, f32::max)); + let err = (0..3).map(|c| (a[c] - b[c]).abs()).fold(0.0, f32::max); + // Builds darker than the press's black point clip at 0 in one or more XYZ channels, + // so the grid interpolates across that clip in the darkest levels. + if b.iter().all(|v| *v < 0.05) { + worst_dark = worst_dark.max(err); + } else { + worst = worst.max(err); + } } } // Within two 8-bit levels: far below a visible difference. assert!(worst * 255.0 < 2.5, "worst error {} levels", worst * 255.0); + assert!(worst_dark * 255.0 < 5.0, "worst error near black {} levels", worst_dark * 255.0); } #[test] diff --git a/crates/render/src/tests/fontchange.rs b/crates/render/src/tests/fontchange.rs index 2ca5b5978..5b0f60091 100644 --- a/crates/render/src/tests/fontchange.rs +++ b/crates/render/src/tests/fontchange.rs @@ -119,6 +119,25 @@ fn variable_font_settings_are_not_highlighted_as_substituted_glyphs() { assert!(g.alternate_glyphs(&t).elements().is_empty()); } +/// Type in a composite font draws each class of character in its part's face, which is not a +/// substitute: Document Setup's highlight of substituted glyphs leaves it alone. +#[test] +fn composite_font_parts_are_not_highlighted_as_substituted_glyphs() { + use vectorcraft_doc::{CompositeFont, CompositePart, CompositeParts}; + let _adding = adding_fonts(); + let db = vectorcraft_text::FontDb::global(); + let mut parts = CompositeParts::all(CompositePart::new("Source Sans 3", "Regular")); + parts.roman = CompositePart::new("JetBrains Mono", "Regular"); + parts.numerals = CompositePart::new("Inter", "Regular"); + db.set_composite_font(&CompositeFont { name: "CF Highlight".into(), parts }); + let style = CharStyle { size: 40.0, font_family: "CF Highlight".into(), ..Default::default() }; + let t = TextObject::point(Point::new(10.0, 60.0), "Ab12", style); + let g = text_geom(&t); + db.remove_composite_font("CF Highlight"); + assert!(!g.all.elements().is_empty()); + assert!(g.substituted_glyphs.elements().is_empty() && g.substituted_fonts.elements().is_empty()); +} + /// Type at 80% opacity, in Multiply and with a drop shadow redraws in a font that becomes /// available: the glyph geometry kept for it is laid out again. #[test] diff --git a/crates/render/src/text_geom.rs b/crates/render/src/text_geom.rs index 5e3c9496a..05228eb20 100644 --- a/crates/render/src/text_geom.rs +++ b/crates/render/src/text_geom.rs @@ -191,7 +191,14 @@ pub(crate) fn text_geom_snapped(t: &TextObject, snap: Option) -> TextGeo if let Some(art) = g.placed_colour(Affine::IDENTITY) { colours.push((g.run, Arc::new(art))); } - let Some(&(missing, face)) = faces.get(g.run) else { continue }; + let Some(&(mut missing, mut face)) = faces.get(g.run) else { continue }; + // Type in a composite font: each character's face is its class's part's. + if let Some(r) = t.runs.get(g.run) + && let Some(c) = plain.get(g.byte..).and_then(|s| s.chars().next()) + && let Some((m, f)) = db.composite_face(&r.style.font_family, c) + { + (missing, face) = (m, Some(f)); + } let Some(line) = layout.lines.get(g.line) else { continue }; let mut cell = Rect::new(g.origin.x, g.origin.y - line.ascent, g.origin.x + g.advance, g.origin.y + line.descent).to_path(0.1); cell.apply_affine(Affine::rotate_about(g.angle, g.origin)); diff --git a/crates/svg/src/export/text.rs b/crates/svg/src/export/text.rs index dd956692d..19bbbd5be 100644 --- a/crates/svg/src/export/text.rs +++ b/crates/svg/src/export/text.rs @@ -1,4 +1,4 @@ -use super::output::{xml_attr_valid, xml_text}; +use super::output::{xml_attr_escape, xml_attr_valid, xml_text}; use super::{ Affine, AppearanceItem, Arc, BTreeSet, BezPath, BlendMode, CharStyle, FillRule, FontDb, FontFace, GradientKind, HashMap, Justify, Node, NodeId, NodeKind, Paint, PathData, Props, Rect, StrokeAlign, Styling, TextKind, TextObject, Writer, base64_encode, css, css_string, fmt_num, @@ -84,11 +84,14 @@ impl Writer<'_> { let inline = t.runs.iter().any(|r| r.inline.is_some()); // Ruby is written as outlines with its base text: a reader's can't place it. So are // small capitals, superscript and subscript and tab leaders, which SVG text doesn't draw (#3869), - // and a glyph chosen for a character, which a reader's draws with the default glyph. - let unwritable = - t.runs.iter().any(|r| { - r.style.small_caps.is_some() || r.style.position != vectorcraft_doc::CharPosition::Normal || r.style.glyph_alternate.is_some() - }) || std::iter::once(&t.para).chain(&t.paras).any(|p| p.tabs.iter().any(|tab| !tab.leader.is_empty())); + // and a glyph chosen for a character, which a reader's draws with the default glyph. So + // is type in one of the document's composite fonts, which a reader can't find by its name. + let unwritable = t.runs.iter().any(|r| { + r.style.small_caps.is_some() + || r.style.position != vectorcraft_doc::CharPosition::Normal + || r.style.glyph_alternate.is_some() + || vectorcraft_doc::CompositeFont::named(&doc.composite_fonts, &r.style.font_family).is_some() + }) || std::iter::once(&t.para).chain(&t.paras).any(|p| p.tabs.iter().any(|tab| !tab.leader.is_empty())); let outlined = self.opts.outline_text || t.vertical || !t.ruby.is_empty() || (inline && matches!(t.kind, TextKind::OnPath { .. })) || unwritable; let chars = |w: &mut Self, n: &Node| if outlined { w.text_outlines(n, t) } else { w.text(n, t) }; @@ -163,10 +166,19 @@ impl Writer<'_> { } let id = self.id_attr(n); let a = self.attrs(&css::transparency(n)); - // Glyphs bent along a path (joined scripts on a curve) can only be outlines: the group - // names its text for screen readers and search. - let label = if lay.glyphs.iter().any(|g| g.bend.is_some()) { - format!(" role=\"img\" aria-label=\"{}\"", xml_attr_valid(&t.plain_text())) + // Text outlined because SVG text can't draw it (bent glyphs, vertical type, ruby, effects on + // a path and the like) names its text for screen readers and search; Convert to Outlines + // leaves only the paths, except for bent glyphs. Inline graphics are written apart from the + // glyphs, so their placeholder characters stay out of the name; empty text has none. + let text: String = t.plain_text().chars().filter(|&c| c != vectorcraft_doc::text::INLINE_CHAR).collect(); + let label = if !text.trim().is_empty() && (!self.opts.outline_text || lay.glyphs.iter().any(|g| g.bend.is_some())) { + // SVG import names an object by its `aria-label` before its id, so an id that stands for + // the name is written as `data-name` too, and the object keeps its name. + let name = match self.names.get(&n.id) { + Some(named) if !id.is_empty() && !id.contains(" data-name=\"") => format!(" data-name=\"{}\"", xml_attr_escape(named)), + _ => String::new(), + }; + format!("{name} role=\"img\" aria-label=\"{}\"", xml_attr_valid(&text)) } else { String::new() }; diff --git a/crates/svg/src/import/css.rs b/crates/svg/src/import/css.rs index 3593a870b..fd864a547 100644 --- a/crates/svg/src/import/css.rs +++ b/crates/svg/src/import/css.rs @@ -18,7 +18,8 @@ pub(super) type XNode<'a, 'i> = roxmltree::Node<'a, 'i>; /// `name: value` declarations of a block or `style` attribute, with their `!important` flag. The /// `font` shorthand becomes its longhands, as usvg reads it for the rest of the drawing (#1557). pub(super) fn parse_decls(s: &str) -> Vec<(String, String, bool)> { - split_decls(s) + let s = strip_comments(s); + split_decls(&s) .into_iter() .filter_map(|d| { let (k, v) = d.split_once(':')?; @@ -354,9 +355,8 @@ fn top_level_commas(list: &str) -> impl Iterator { }) } -/// Rules from every `