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/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/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/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index f6491d757..19c64859e 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -930,7 +930,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/screens.rs b/crates/engine/src/cmd/fileio/screens.rs index b69cfd177..803109943 100644 --- a/crates/engine/src/cmd/fileio/screens.rs +++ b/crates/engine/src/cmd/fileio/screens.rs @@ -21,8 +21,8 @@ use super::encode::single_artboard; use super::export::{export_crop, isolated, selected_artwork, without_artboards}; use super::naming::{self, NameParts}; use super::{ - ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names, create_dir, distinct_names_by, encode_with_warnings, merge, unique_file_names, - with_black_output, writable, write_file, + ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names_keeping_dots, create_dir, distinct_names_by, encode_with_warnings, merge, + unique_file_names_keeping_dots, with_black_output, writable, write_file, }; const C: &str = "document.exportForScreens"; @@ -1184,7 +1184,7 @@ fn items( // the item folders are (`fold`). let safe = |stems: Vec| 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_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/fonts.rs b/crates/engine/src/cmd/fonts.rs index f648a00a3..ba91f87fa 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![ @@ -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 ), @@ -713,6 +714,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(); @@ -758,11 +760,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") { @@ -911,6 +967,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/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/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index 83dfd925d..872ceee4b 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -611,6 +611,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 +846,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/lib.rs b/crates/engine/src/lib.rs index dc19b9edb..c9533158d 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>, diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index ab258887f..d4859cdf1 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(), 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/tooling.rs b/crates/engine/src/tooling.rs index 8ecc738f2..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) } 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..5499dfd88 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 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/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/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 3d95c9485..d5d28aab6 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) } @@ -509,7 +523,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 +1203,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/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..e5c927670 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -5778,6 +5778,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 +6069,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 +7648,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 13382e533..d3d9c2bdd 100644 --- a/crates/eps/src/import/interp.rs +++ b/crates/eps/src/import/interp.rs @@ -73,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, @@ -94,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; @@ -189,6 +198,8 @@ impl<'a> Interp<'a> { journal: vec![], fault: None, glyph_width: None, + type42: vec![], + type42_counted: HashSet::new(), g, saved: vec![], out, @@ -362,7 +373,7 @@ 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) } diff --git a/crates/eps/src/import/tests.rs b/crates/eps/src/import/tests.rs index 80fe1125c..601c64cb5 100644 --- a/crates/eps/src/import/tests.rs +++ b/crates/eps/src/import/tests.rs @@ -1125,7 +1125,7 @@ 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); 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/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/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/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/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 `