mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-10 06:31:19 +00:00
Merge main into fix/fitted-gradient-turns-with-transform
This commit is contained in:
Generated
+1
@@ -6338,6 +6338,7 @@ dependencies = [
|
||||
"memchr",
|
||||
"ruzstd",
|
||||
"serde_json",
|
||||
"skrifa 0.47.0",
|
||||
"vectorcraft-brush",
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
|
||||
@@ -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_")?))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
//! (<https://learn.microsoft.com/en-us/windows/win32/inputdev/about-keyboard-input#extended-key-flag>),
|
||||
//! 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<u16>,
|
||||
/// 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<usize>,
|
||||
}
|
||||
|
||||
/// 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<usize> {
|
||||
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<Event>) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,14 @@ const MAX_DOCUMENT_CELLS: usize = 1 << 18;
|
||||
/// Points of all strokes' pressure curves together (a stroke style may be shared by many layers).
|
||||
const MAX_DOCUMENT_PRESSURE_POINTS: usize = 1 << 20;
|
||||
|
||||
/// Child references inspected, page rectangles produced, and outline segments retained, in all
|
||||
/// (shared objects are visited once per link, so each visit is charged). Real documents stay
|
||||
/// within a few times [`MAX_NODES`]; this leaves ~30x headroom over that.
|
||||
const MAX_DOCUMENT_WORK: usize = 1 << 24;
|
||||
|
||||
/// Owned layer-name characters kept in all (each visit of a shared object keeps its own copy).
|
||||
const MAX_DOCUMENT_NAME_CHARS: usize = 16 << 20;
|
||||
|
||||
/// What a whole read may still use. Shared objects can be linked from many layers, and embedded
|
||||
/// documents are read with what their embedding document left, so linking or embedding the
|
||||
/// same content many times can't multiply the work.
|
||||
@@ -44,6 +52,10 @@ pub(crate) struct Budget {
|
||||
pub(crate) cells: usize,
|
||||
/// Points of pressure curves ([`MAX_DOCUMENT_PRESSURE_POINTS`] in all).
|
||||
pub(crate) pressure: usize,
|
||||
/// Child references, page rectangles and outline segments ([`MAX_DOCUMENT_WORK`] in all).
|
||||
pub(crate) work: usize,
|
||||
/// Layer-name characters ([`MAX_DOCUMENT_NAME_CHARS`] in all).
|
||||
pub(crate) name_chars: usize,
|
||||
}
|
||||
|
||||
impl Budget {
|
||||
@@ -53,7 +65,15 @@ impl Budget {
|
||||
chars: MAX_DOCUMENT_CHARS,
|
||||
cells: MAX_DOCUMENT_CELLS,
|
||||
pressure: MAX_DOCUMENT_PRESSURE_POINTS,
|
||||
work: MAX_DOCUMENT_WORK,
|
||||
name_chars: MAX_DOCUMENT_NAME_CHARS,
|
||||
};
|
||||
|
||||
/// Take `n` units of work, or fail the read.
|
||||
pub(crate) fn spend(&mut self, n: usize) -> Result<(), Error> {
|
||||
self.work = self.work.checked_sub(n).ok_or(Error::Limit("too much shared geometry or page data"))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a document: archive, `doc.dat` and the model.
|
||||
@@ -192,6 +212,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
return Ok(None);
|
||||
}
|
||||
let pages = pages(s, spread);
|
||||
self.budget.spend(pages.len() + 1)?;
|
||||
let default_size = s.floats::<2>(doc, b"DfSz").filter(|[w, h]| *w > 0.0 && *h > 0.0).map(|[w, h]| Rect { x0: 0.0, y0: 0.0, x1: w, y1: h });
|
||||
let bounds = s.floats::<4>(spread, b"SprB").map(rect).or_else(|| pages.iter().copied().reduce(union)).or(default_size).unwrap_or(Rect {
|
||||
x0: 0.0,
|
||||
@@ -214,7 +235,10 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
let s = self.s;
|
||||
let mut out = Vec::new();
|
||||
let mut mask = None;
|
||||
for c in s.objs(parent, b"Chld") {
|
||||
let kids = s.objs(parent, b"Chld");
|
||||
// Invisible masks and skipped children cost no node, so the visit is charged here.
|
||||
self.budget.spend(kids.len())?;
|
||||
for c in kids {
|
||||
if s.is(c, b"MRst") {
|
||||
// A mask layer inside a group or layer masks that container.
|
||||
if s.bool(c, b"Visi") == Some(false) {
|
||||
@@ -254,7 +278,8 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
let world = local.then(parent);
|
||||
let class = s.class(id).unwrap_or(Tag(0));
|
||||
// A layer name is short; a long one is cut, as every visit of a shared object keeps a copy.
|
||||
let name: String = s.str(id, b"Desc").unwrap_or_default().chars().take(MAX_NAME_CHARS).collect();
|
||||
let name: String = s.str(id, b"Desc").unwrap_or_default().chars().take(MAX_NAME_CHARS.min(self.budget.name_chars)).collect();
|
||||
self.budget.name_chars -= name.chars().count();
|
||||
let visible = s.bool(id, b"Visi").unwrap_or(true);
|
||||
let locked = s.bool(id, b"Edtb").is_some_and(|e| !e);
|
||||
let opacity = s.f64(id, b"Opac").map_or(1.0, |o| o.clamp(0.0, 1.0));
|
||||
@@ -312,6 +337,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
self.warn(format!("a {} that could not be read", describe(class)));
|
||||
return Ok(Kind::Unsupported);
|
||||
};
|
||||
self.budget.spend(local.subpaths.iter().map(|sp| sp.segments.len() + 1).sum())?;
|
||||
let (fills, strokes) = paint::node_paint(self, id, world);
|
||||
Kind::Shape { path: local.transformed(world), fills, strokes, even_odd }
|
||||
}
|
||||
@@ -645,3 +671,15 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
Kind::Artboard { rect, path: path.transformed(world), even_odd, background, strokes }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod budget_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn shared_work_is_bounded() {
|
||||
let mut b = Budget::FULL;
|
||||
assert!(b.spend(MAX_DOCUMENT_WORK).is_ok());
|
||||
assert_eq!(b.spend(1), Err(Error::Limit("too much shared geometry or page data")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,12 +238,13 @@ pub(crate) fn descriptor(r: &mut Reader, id: ObjId, local: Affine) -> Option<Pai
|
||||
};
|
||||
let grad = s.obj(fill, b"Grad")?;
|
||||
let cols = s.objs(grad, b"Cols");
|
||||
let posn: Vec<Vec<f64>> = match s.field(grad, b"Posn") {
|
||||
Some(Value::Array(a)) => a.iter().filter_map(floats).collect(),
|
||||
_ => Vec::new(),
|
||||
// Convert only the positions that pair with a colour, as shared attributes repeat this per run.
|
||||
let posn: &[Value] = match s.field(grad, b"Posn") {
|
||||
Some(Value::Array(a)) => a,
|
||||
_ => &[],
|
||||
};
|
||||
let mut stops = Vec::new();
|
||||
for (c, p) in cols.iter().zip(&posn).take(MAX_STOPS) {
|
||||
for (c, p) in cols.iter().zip(posn.iter().filter_map(floats)).take(MAX_STOPS) {
|
||||
let color = color(r, *c)?;
|
||||
let offset = p.first().copied().filter(|v| v.is_finite()).unwrap_or(0.0).clamp(0.0, 1.0);
|
||||
let bias = p.get(1).copied().filter(|v| v.is_finite()).unwrap_or(0.5).clamp(0.0, 1.0);
|
||||
|
||||
@@ -195,6 +195,11 @@ fn decode_within(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), worl
|
||||
if let Some(name) = s.entry(bitmap, b"Bckg") {
|
||||
let width = s.int(bitmap, b"BmpW").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
|
||||
let height = s.int(bitmap, b"BmpH").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
|
||||
// The whole source is decoded for every layer that reads it, so charge it all, not only the crop.
|
||||
let Some(left) = r.budget.pixels.checked_sub(u64::from(width) * u64::from(height)) else {
|
||||
return Err(Error::Limit("embedded image pixels"));
|
||||
};
|
||||
r.budget.pixels = left;
|
||||
match original(r, name).and_then(|bytes| source_pixels(&bytes, width, height)) {
|
||||
Ok(pixels) => Some((width as usize, pixels)),
|
||||
Err(Error::Limit(e)) => return Err(Error::Limit(e)),
|
||||
@@ -318,8 +323,10 @@ fn grid(s: &stream::Stream, bitmap: ObjId, c: usize, bps: usize) -> (usize, usiz
|
||||
let height = s.int(bitmap, b"BmpH").and_then(|v| usize::try_from(v).ok()).unwrap_or(0);
|
||||
let tw = s.int(bitmap, &tag(b"TWi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| width.saturating_mul(bps).div_ceil(TILE));
|
||||
let th = s.int(bitmap, &tag(b"THi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| height.div_ceil(TILE));
|
||||
// A grid larger than the bitmap has tiles no crop can reach: don't convert or scan them on every reference.
|
||||
let tiles = width.saturating_mul(bps).div_ceil(TILE).saturating_mul(height.div_ceil(TILE));
|
||||
let states: Vec<u8> = match s.field(bitmap, &tag(b"Sta", c)) {
|
||||
Some(Value::Array(a)) => a.iter().map(|v| if let Value::UInt(u) = v { u8::try_from(*u).unwrap_or(255) } else { 255 }).collect(),
|
||||
Some(Value::Array(a)) => a.iter().take(tiles).map(|v| if let Value::UInt(u) = v { u8::try_from(*u).unwrap_or(255) } else { 255 }).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
(tw, th, states)
|
||||
|
||||
@@ -51,6 +51,9 @@ const MAX_TABLE_LINES: usize = 4096;
|
||||
/// Cells a table may have (rows × columns): each becomes a text object with its edge lines.
|
||||
const MAX_TABLE_CELLS: usize = 1 << 16;
|
||||
|
||||
/// Work charged for each copy of a run's formatting a table cell keeps.
|
||||
const TABLE_RUN_COST: usize = 16;
|
||||
|
||||
/// A table node (`TxtT`): the grid, the cells' text and the lines on the cell edges.
|
||||
pub(crate) fn table(r: &mut Reader, id: ObjId, world: Affine) -> Option<Table> {
|
||||
let s = r.s;
|
||||
@@ -108,9 +111,10 @@ pub(crate) fn table(r: &mut Reader, id: ObjId, world: Affine) -> Option<Table> {
|
||||
let mut parts = run.text.split(CELL_BREAK).peekable();
|
||||
while let Some(part) = parts.next() {
|
||||
if !part.is_empty() {
|
||||
// Each cell keeps its own copy of the run's formatting, so each copy is charged.
|
||||
match target.and_then(|i| cells.get_mut(i)) {
|
||||
Some(cell) => cell.runs.push(TextRun { text: part.to_string(), ..run.clone() }),
|
||||
None => extra = true,
|
||||
Some(cell) if r.budget.spend(TABLE_RUN_COST).is_ok() => cell.runs.push(TextRun { text: part.to_string(), ..run.clone() }),
|
||||
_ => extra = true,
|
||||
}
|
||||
}
|
||||
if parts.peek().is_some() {
|
||||
@@ -157,6 +161,13 @@ pub(crate) fn story_runs(r: &mut Reader, story: ObjId, world: Affine) -> (Vec<Te
|
||||
let mut placeholders = false;
|
||||
let mut attribute_budget = Some(MAX_ATTRIBUTE_BYTES);
|
||||
for block in s.objs(story, b"Blok") {
|
||||
// Shared blocks and run lists are visited once per link, so every visit is charged, empty or not.
|
||||
let paragraph_runs = s.obj(block, b"PAtt").map(|p| s.objs(p, b"Runs")).unwrap_or_default();
|
||||
let attribute_runs = s.obj(block, b"GAtt").map(|g| s.objs(g, b"Runs")).unwrap_or_default();
|
||||
if r.budget.spend(1 + paragraph_runs.len() + attribute_runs.len()).is_err() {
|
||||
r.warn("text beyond the work one document may hold (truncated)");
|
||||
break;
|
||||
}
|
||||
let Some(glyphs) = s.obj(block, b"Glyp") else {
|
||||
r.warn("a text block without readable characters (left out)");
|
||||
continue;
|
||||
@@ -209,7 +220,7 @@ pub(crate) fn story_runs(r: &mut Reader, story: ObjId, world: Affine) -> (Vec<Te
|
||||
// Paragraph formats by end index; a run without an index covers the rest of the block.
|
||||
let mut paragraphs: Vec<(usize, Paragraph)> = Vec::new();
|
||||
let mut open: Option<Paragraph> = None;
|
||||
for run in s.obj(block, b"PAtt").map(|p| s.objs(p, b"Runs")).unwrap_or_default() {
|
||||
for run in paragraph_runs {
|
||||
let Some(attrs) = s.obj(run, b"Item") else { continue };
|
||||
let a = paragraph(r, attrs);
|
||||
first_align.get_or_insert(a.align);
|
||||
@@ -238,7 +249,7 @@ pub(crate) fn story_runs(r: &mut Reader, story: ObjId, world: Affine) -> (Vec<Te
|
||||
}
|
||||
};
|
||||
let mut start = 0usize;
|
||||
for run in s.obj(block, b"GAtt").map(|g| s.objs(g, b"Runs")).unwrap_or_default() {
|
||||
for run in attribute_runs {
|
||||
let Some(index) = s.int(run, b"Indx").and_then(|v| usize::try_from(v).ok()) else {
|
||||
r.warn("invalid text attribute range (bounded; unassigned characters use defaults)");
|
||||
continue;
|
||||
|
||||
@@ -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::<u64>().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::<u64>().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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +120,21 @@ impl Quotes {
|
||||
prev.is_none_or(|p| p.is_whitespace() || matches!(p, '(' | '[' | '{' | '—' | '–') || quote_opened.unwrap_or_else(|| self.is_open(p)))
|
||||
}
|
||||
|
||||
/// The straight quote a typed curly quote stands for: `"` for `”` (U+201D) and nothing else.
|
||||
/// The macOS Serbian Cyrillic layout has no straight `"` and types `”` on Shift+2, so with
|
||||
/// Typographer's Quotes on, a `”` typed alone where a quote opens takes the document's opening
|
||||
/// quote ([`Quotes::opens_after`]); where a quote closes it stays `”`. `“ „` and the single
|
||||
/// quotes `‘ ’ ‚` stay as typed: that layout's apostrophe key already types `’`, as Serbian
|
||||
/// wants, and `’90s` keeps its apostrophe.
|
||||
pub fn typed_as_straight(c: char) -> Option<char> {
|
||||
(c == '”').then_some('"')
|
||||
}
|
||||
|
||||
/// Does a quote typed after `prev` (the character before it, `None` at the start) open?
|
||||
pub fn opens_after(&self, prev: Option<char>) -> bool {
|
||||
self.opens(prev, None)
|
||||
}
|
||||
|
||||
/// `text` with straight quotes (`"` and `'`) replaced by these quotes: opening or closing by
|
||||
/// context (an apostrophe is a closing single quote). `prev` is the character before `text`,
|
||||
/// and is updated to its last character, so text can be converted in pieces.
|
||||
@@ -420,6 +435,12 @@ mod tests {
|
||||
}
|
||||
let mut prev = None;
|
||||
assert_eq!(language_quotes("croatian").unwrap().apply("\"a 'b' c\"", &mut prev), "„a ‚b‘ c“");
|
||||
// Only a typed `”` stands for a straight quote; the other curly quotes stay as typed.
|
||||
assert_eq!(Quotes::typed_as_straight('”'), Some('"'));
|
||||
assert!(['“', '„', '‘', '’', '‚', '"', '\'', '«', '»', '「', 'a'].iter().all(|&c| Quotes::typed_as_straight(c).is_none()));
|
||||
// A quote opens at the start and after a space, and closes after a letter.
|
||||
assert!(Quotes::ENGLISH.opens_after(None) && Quotes::ENGLISH.opens_after(Some(' ')));
|
||||
assert!(!Quotes::ENGLISH.opens_after(Some('a')));
|
||||
// Bulgarian: „…“ at either level, as CLDR has it.
|
||||
let mut prev = None;
|
||||
assert_eq!(language_quotes("bulgarian").unwrap().apply("\"a 'b' c\"", &mut prev), "„a „b“ c“");
|
||||
|
||||
@@ -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<bool>) -> (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<bool>,
|
||||
let n = pts.len();
|
||||
let seg_count = if closed { n } else { n - 1 };
|
||||
let dir: Vec<Vec2> = (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<f64> = (0..n).map(|i| pts[i].distance(pts[(i + 1) % n])).collect();
|
||||
let along: Vec<f64> = 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<f64> = (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<f64> = (0..n).map(|i| seg[(2 * n - 2 - i) % n]).collect();
|
||||
let back_cover: Vec<f64> = cover.iter().rev().copied().collect();
|
||||
let behind: Vec<f64> = 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<bool>,
|
||||
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<f64> {
|
||||
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<usize> = 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
|
||||
}
|
||||
|
||||
@@ -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<bool> {
|
||||
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<f64> = (0..n).map(|_| rnd() * 10.0).collect();
|
||||
// Some points on top of each other.
|
||||
let seg: Vec<f64> = (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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1004,7 +1004,6 @@ fn replace_fonts(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
edit_symbol_texts(d, None, &|t, _| lacks(t), &mut replace);
|
||||
drop(replace);
|
||||
Ok(runs)
|
||||
})?;
|
||||
Ok(json!({ "runs": runs, "font": family }))
|
||||
|
||||
@@ -930,7 +930,10 @@ fn without_inner(doc: &Document, placed: Vec<(NodeId, Option<Vec<usize>>)>) -> i
|
||||
/// The direct-selection flag and resolved root nodes for Copy and Cut operations.
|
||||
fn copy_cut_targets(s: &Session) -> Result<(bool, Vec<NodeId>)> {
|
||||
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| {
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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<String> {
|
||||
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<String> {
|
||||
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<Item = (&'a str, String)> {
|
||||
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<Item = (&'a str, String)>) -> Vec<String> {
|
||||
fn unique_file_names<'a>(names: impl IntoIterator<Item = (&'a str, String)>) -> Vec<String> {
|
||||
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<Item = (&'a str, String)>) -> Vec<String> {
|
||||
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<Item = String>) -> Vec<String> {
|
||||
distinct_names_by(names, str::to_lowercase)
|
||||
|
||||
@@ -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<String>| distinct_names_by(stems.iter().map(|s| safe_file_name(s)), fold);
|
||||
let named = |pieces: Vec<(String, Arc<Document>, Option<usize>, Rect)>, fallback: &str| -> Vec<Item> {
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<CommandSpec> {
|
||||
vec![
|
||||
@@ -38,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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
|
||||
),
|
||||
@@ -689,7 +690,6 @@ fn replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
edit_symbol_texts(d, symbols.as_ref(), &|t, open| open && changes(t), &mut replace_in);
|
||||
drop(replace_in);
|
||||
Ok(n)
|
||||
})?
|
||||
};
|
||||
@@ -714,6 +714,7 @@ fn select_font(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let style = str_param(p, "style").map(str::to_string);
|
||||
let ids = scope(s, false, None)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let within = FontScope::read(d, p)?;
|
||||
// Only text the Selection tool can reach: not hidden or locked, in a hidden or locked group
|
||||
// or layer, or on a locked artboard.
|
||||
let editable = d.editable();
|
||||
@@ -759,11 +760,65 @@ fn select_font(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
});
|
||||
}
|
||||
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<Rect>),
|
||||
/// The layers and sublayers, with what they hold.
|
||||
Layers(BTreeSet<NodeId>),
|
||||
}
|
||||
|
||||
impl FontScope {
|
||||
fn read(d: &Document, p: &Value) -> Result<Self> {
|
||||
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<Option<&Vec<Value>>> {
|
||||
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::<Result<_>>().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::<Result<_>>()
|
||||
.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<Value> {
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
match str_param(p, "family") {
|
||||
@@ -912,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]
|
||||
|
||||
@@ -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<String> {
|
||||
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::<u32>().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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -611,6 +611,9 @@ pub(crate) fn alt_code_char(code: u64) -> Option<char> {
|
||||
fn unicode_char(u: &str) -> Option<char> {
|
||||
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<Value> {
|
||||
}
|
||||
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('°'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Box<dyn Tool>>,
|
||||
/// 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>,
|
||||
@@ -249,6 +257,9 @@ pub struct Session {
|
||||
/// The Global Edit Options Start Global Edit matches with (`select.globalEditOptions`); not
|
||||
/// saved.
|
||||
pub global_edit_options: cmd::globaledit::Options,
|
||||
/// Set while [`Session::tool_typed_text`] passes keyboard input to the active tool
|
||||
/// ([`vectorcraft_tools::ToolContext::typed_from_keyboard`]).
|
||||
keyboard_text: bool,
|
||||
}
|
||||
|
||||
impl Default for Session {
|
||||
|
||||
@@ -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(),
|
||||
@@ -110,6 +112,7 @@ impl Session {
|
||||
link_stamps: Default::default(),
|
||||
link_scan: None,
|
||||
global_edit_options: Default::default(),
|
||||
keyboard_text: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,16 +28,28 @@ fn create_text(s: &mut Session, t: &str) -> NodeId {
|
||||
NodeId(s.execute("text.create", &json!({"x": 10, "y": 50, "text": t, "size": 20})).unwrap()["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
/// Click with the Type tool at (x, y) and type `keys` one at a time; returns the new text object.
|
||||
/// Click with the Type tool at (x, y) and type `keys` one at a time on the keyboard; returns the
|
||||
/// new text object.
|
||||
fn type_at(s: &mut Session, x: f64, y: f64, keys: &[&str]) -> NodeId {
|
||||
enter_at(s, x, y, keys, true)
|
||||
}
|
||||
|
||||
/// Click with the Type tool at (x, y) and enter `texts` one at a time, typed on the keyboard
|
||||
/// ([`Session::tool_typed_text`]) or inserted as the Glyphs panel and pastes do
|
||||
/// ([`Session::tool_text`]); returns the new text object.
|
||||
fn enter_at(s: &mut Session, x: f64, y: f64, texts: &[&str], keyboard: bool) -> NodeId {
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("type", v).unwrap();
|
||||
for k in [PointerKind::Down, PointerKind::Up] {
|
||||
s.pointer(&PointerEvent::new(k, x, y), v).unwrap();
|
||||
}
|
||||
let id = s.doc().unwrap().selection.objects[0];
|
||||
for k in keys {
|
||||
s.tool_text(k, v).unwrap();
|
||||
for t in texts {
|
||||
if keyboard {
|
||||
s.tool_typed_text(t, v).unwrap();
|
||||
} else {
|
||||
s.tool_text(t, v).unwrap();
|
||||
}
|
||||
}
|
||||
s.tool_key(ToolKey::Escape, Default::default(), v).unwrap();
|
||||
id
|
||||
@@ -177,6 +189,53 @@ fn typographers_quotes_setting_changes_typed_quotes() {
|
||||
assert_eq!(text(&s, id).plain_text(), "\"b'");
|
||||
}
|
||||
|
||||
/// A lone typed `”` is set like a straight quote where a quote opens: the macOS Serbian Cyrillic
|
||||
/// layout has no straight quote and types `”` on Shift+2 (#5247), so Serbian text still gets
|
||||
/// „…”. Where a quote closes it stays `”`. `“ „` and the single quotes stay as typed, so `’90s`
|
||||
/// keeps its apostrophe. Pasted text and the Glyphs panel keep their quotes, and with the option
|
||||
/// off `”` stays as typed.
|
||||
#[test]
|
||||
fn a_typed_closing_double_quote_opens_where_a_quote_opens() {
|
||||
let mut s = session();
|
||||
s.execute("document.setup", &json!({"language": "Serbian: Cyrillic"})).unwrap();
|
||||
let id = type_at(&mut s, 50.0, 50.0, &["”", "П", "р", "о", "б", "а", "”"]);
|
||||
assert_eq!(text(&s, id).plain_text(), "„Проба”");
|
||||
// The other curly quotes stay as typed.
|
||||
let id = type_at(&mut s, 50.0, 100.0, &["„", "a", " ", "’", "b", "’", "“"]);
|
||||
assert_eq!(text(&s, id).plain_text(), "„a ’b’“");
|
||||
// In English a `”` that opens is `“`; the apostrophes stay.
|
||||
s.execute("document.setup", &json!({"language": "English: USA"})).unwrap();
|
||||
let id = type_at(&mut s, 50.0, 150.0, &["”", "i", "t", "’", "s", "”"]);
|
||||
assert_eq!(text(&s, id).plain_text(), "“it’s”");
|
||||
// An apostrophe after a space stays an apostrophe, and a typed `‘` stays `‘`.
|
||||
let id = type_at(&mut s, 50.0, 200.0, &["t", "h", "e", " ", "’", "9", "0", "s", " ", "‘", "a"]);
|
||||
assert_eq!(text(&s, id).plain_text(), "the ’90s ‘a");
|
||||
// Where a quote closes, `”` stays `”` even if the document's closing quote is another.
|
||||
s.execute("document.setup", &json!({"language": "German"})).unwrap();
|
||||
let id = type_at(&mut s, 50.0, 250.0, &["”", "a", "”"]);
|
||||
assert_eq!(text(&s, id).plain_text(), "„a”");
|
||||
// More than one character is pasted text, not a typed quote.
|
||||
let id = type_at(&mut s, 50.0, 300.0, &["”a”"]);
|
||||
assert_eq!(text(&s, id).plain_text(), "”a”");
|
||||
// An IME commit of a lone `”` that will open is not verbatim (it is rewritten); from the Glyphs
|
||||
// panel it would be inserted as it is.
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("type", v).unwrap();
|
||||
for k in [PointerKind::Down, PointerKind::Up] {
|
||||
s.pointer(&PointerEvent::new(k, 50.0, 312.0), v).unwrap();
|
||||
}
|
||||
assert!(!s.tool_typed_text_is_verbatim("”", v));
|
||||
assert!(s.tool_text_is_verbatim("”", v));
|
||||
s.tool_key(ToolKey::Escape, Default::default(), v).unwrap();
|
||||
// A `”` inserted from the Glyphs panel or pasted alone stays as inserted.
|
||||
let id = enter_at(&mut s, 50.0, 325.0, &["”", "a"], false);
|
||||
assert_eq!(text(&s, id).plain_text(), "”a");
|
||||
// With the option off a typed `”` stays as typed.
|
||||
s.execute("document.setup", &json!({"typographersQuotes": false})).unwrap();
|
||||
let id = type_at(&mut s, 50.0, 350.0, &["”", "b", "”"]);
|
||||
assert_eq!(text(&s, id).plain_text(), "”b”");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smart_punctuation_uses_the_document_quotes() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -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<NodeId> = 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::<Vec<_>>()
|
||||
};
|
||||
// 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::<Vec<_>>();
|
||||
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
|
||||
|
||||
@@ -219,6 +219,7 @@ impl Session {
|
||||
// The Selection and Group Selection tools act on the instance whole.
|
||||
self.select_whole_instance(id);
|
||||
let lent = self.make_tool(id);
|
||||
self.selection_at_lend = self.active().map(|d| (d.uid, d.selection.clone()));
|
||||
self.lender = Some(std::mem::replace(&mut self.tool, lent));
|
||||
PointerEvent { mods: Mods { cmd: false, ..ev.mods }, ..*ev }
|
||||
}
|
||||
@@ -232,6 +233,12 @@ impl Session {
|
||||
let r = self.apply_actions(acts);
|
||||
self.keep_tool_settings();
|
||||
self.tool = lender;
|
||||
// A gesture that left the selection as it found it (a Shift marquee over nothing) picked
|
||||
// nothing: the selection keeps what it was, the Pen's last anchor or an earlier pick.
|
||||
let left = self.active().map(|d| (d.uid, d.selection.clone()));
|
||||
if left != self.selection_at_lend.take() {
|
||||
self.lent_selection = left;
|
||||
}
|
||||
self.after_command(None);
|
||||
r.map(drop)
|
||||
}
|
||||
@@ -333,6 +340,7 @@ impl Session {
|
||||
unit,
|
||||
stroke_unit,
|
||||
paste_plain_text: self.prefs.paste_text_formatting == "plain",
|
||||
typed_from_keyboard: self.keyboard_text,
|
||||
slices_hidden: self.menu.slices_hidden,
|
||||
slices_locked: self.menu.slices_locked,
|
||||
auto_add_delete: !self.prefs.disable_auto_add_delete,
|
||||
@@ -616,6 +624,17 @@ impl Session {
|
||||
self.apply_actions(acts)
|
||||
}
|
||||
|
||||
/// Text typed on the keyboard (a key or an IME commit) for the active tool: as
|
||||
/// [`Session::tool_text`], and Typographer's Quotes may also set a lone typed `”` as an opening
|
||||
/// quote ([`vectorcraft_tools::ToolContext::typed_from_keyboard`]). The Glyphs panel and pastes
|
||||
/// use [`Session::tool_text`], so their quotes stay as inserted.
|
||||
pub fn tool_typed_text(&mut self, text: &str, view: ViewInfo) -> Result<Vec<UiRequest>> {
|
||||
self.keyboard_text = true;
|
||||
let r = self.tool_text(text, view);
|
||||
self.keyboard_text = false;
|
||||
r
|
||||
}
|
||||
|
||||
/// Would [`Session::tool_text`] insert `text` unchanged (no control-character filtering,
|
||||
/// newline normalization or document formatting such as Typographer's Quotes)? An IME commit
|
||||
/// the application rewrites is an application edit, not an accepted IME insertion.
|
||||
@@ -623,6 +642,16 @@ impl Session {
|
||||
self.with_tool_cx(view, |t, cx| t.text_input_is_verbatim(cx, text))
|
||||
}
|
||||
|
||||
/// [`Session::tool_text_is_verbatim`] for text typed on the keyboard, as
|
||||
/// [`Session::tool_typed_text`] would insert it: a lone typed `”` that Typographer's Quotes
|
||||
/// turns into an opening quote is not verbatim.
|
||||
pub fn tool_typed_text_is_verbatim(&mut self, text: &str, view: ViewInfo) -> bool {
|
||||
self.keyboard_text = true;
|
||||
let v = self.tool_text_is_verbatim(text, view);
|
||||
self.keyboard_text = false;
|
||||
v
|
||||
}
|
||||
|
||||
/// Does the active tool's open typing session still match the document? An IME commit after
|
||||
/// an external edit starts a fresh session (an application-side reset), so its receipt must
|
||||
/// not be accepted as an unchanged IME insertion.
|
||||
|
||||
@@ -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"})),
|
||||
|
||||
@@ -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::<u8>()), 0..12),
|
||||
body in prop::collection::vec((0usize..TYPE42_FONT.len(), any::<u8>()), 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::<String>())).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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -67,20 +67,29 @@ pub(super) fn color_op(op: &str, vals: &[V]) -> Option<(Paint, Option<Named>)> {
|
||||
"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<bool>) -> Option<(Paint, Option<Named>)> {
|
||||
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<f32> = vals.get(..at)?.iter().filter_map(V::num).map(|v| v as f32).collect();
|
||||
let after: Vec<f32> = 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<bool>) -> Option<(Paint, Option<Named>)> {
|
||||
|
||||
/// `… (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<Named>)> {
|
||||
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<Named>)> {
|
||||
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]);
|
||||
|
||||
@@ -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<AppearanceItem> {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -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<Dash>,
|
||||
profile: Option<WidthProfile>,
|
||||
/// 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<String, ArtStyle> {
|
||||
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<Vec<u8>> {
|
||||
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<Option<StyleItem>, ()> {
|
||||
(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<Option<StyleItem>, ()> {
|
||||
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<Option<StyleItem>, ()> {
|
||||
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<StyleItem> {
|
||||
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<StyleItem> {
|
||||
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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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<u8>) -> 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::<String>();
|
||||
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() {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(());
|
||||
}
|
||||
|
||||
@@ -15,8 +15,11 @@ use super::graphics::{GState, Out};
|
||||
use super::lex::{IMMEDIATE, Lexer, number};
|
||||
use super::obj::{Dict, DictRef, Key, Obj, Op, PsError, Res, Shared, ps_err};
|
||||
|
||||
/// Most operations one file may run.
|
||||
/// Most operations one file may run, whatever its length.
|
||||
const MAX_OPS: u64 = 50_000_000;
|
||||
/// More operations a file may run for each byte of its program: a large file that draws a lot
|
||||
/// runs proportionally more, and stays bounded by its length.
|
||||
const OPS_PER_BYTE: u64 = 64;
|
||||
/// Deepest procedure nesting.
|
||||
const MAX_DEPTH: u32 = 400;
|
||||
/// Most operands on the stack.
|
||||
@@ -55,6 +58,8 @@ pub(crate) struct Interp<'a> {
|
||||
resources: DictRef,
|
||||
depth: u32,
|
||||
ops: u64,
|
||||
/// Most operations this program may run: [`MAX_OPS`] and [`OPS_PER_BYTE`] for each byte.
|
||||
max_ops: u64,
|
||||
/// Bytes asked for so far (see [`MAX_MEMORY`]).
|
||||
allocated: usize,
|
||||
seed: u32,
|
||||
@@ -68,6 +73,12 @@ pub(crate) struct Interp<'a> {
|
||||
pub fault: Option<Fault>,
|
||||
/// 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<Shared<Obj>>,
|
||||
pub g: GState,
|
||||
pub saved: Vec<GState>,
|
||||
pub out: Out,
|
||||
@@ -89,6 +100,9 @@ pub(crate) struct Fault {
|
||||
pub procs: Vec<Rc<str>>,
|
||||
}
|
||||
|
||||
/// The TrueType data of Type 42 fonts by their `sfnts` array (see [`Interp::type42`]).
|
||||
pub(crate) type Type42Cache = Vec<(Shared<Obj>, Option<Rc<Vec<u8>>>)>;
|
||||
|
||||
/// Most procedure names a [`Fault`] keeps.
|
||||
const FAULT_PROCS: usize = 3;
|
||||
|
||||
@@ -177,12 +191,15 @@ impl<'a> Interp<'a> {
|
||||
resources,
|
||||
depth: 0,
|
||||
ops: 0,
|
||||
max_ops: MAX_OPS.saturating_add((src.len() as u64).saturating_mul(OPS_PER_BYTE)),
|
||||
allocated: 0,
|
||||
seed: 1,
|
||||
saves: vec![],
|
||||
journal: vec![],
|
||||
fault: None,
|
||||
glyph_width: None,
|
||||
type42: vec![],
|
||||
type42_counted: HashSet::new(),
|
||||
g,
|
||||
saved: vec![],
|
||||
out,
|
||||
@@ -356,15 +373,15 @@ impl<'a> Interp<'a> {
|
||||
|
||||
/// Charge for work on `n` bytes or objects in one operator: one operation per 256, as `search`
|
||||
/// charges per 64 bytes, so a loop of big copies runs out of the budget.
|
||||
fn spend_bytes(&mut self, n: usize) -> Res {
|
||||
pub fn spend_bytes(&mut self, n: usize) -> Res {
|
||||
self.spend((n / 256) as u64)
|
||||
}
|
||||
|
||||
/// Count `ops` operations' worth of work against [`MAX_OPS`]: what an operator that costs
|
||||
/// Count `ops` operations' worth of work against the budget (`max_ops`): what an operator that costs
|
||||
/// far more than one operation (laying out type) does in a loop has to end too.
|
||||
pub fn spend(&mut self, ops: u64) -> Res {
|
||||
self.ops = self.ops.saturating_add(ops);
|
||||
if self.ops > MAX_OPS {
|
||||
if self.ops > self.max_ops {
|
||||
return Err(PsError::Limit("the program runs too long"));
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -1125,12 +1125,29 @@ fn read_within(body: &str, secs: u64) -> crate::Imported {
|
||||
/// budget. Running to the end of the budget can take over 20 s on a slow CI runner
|
||||
/// (`search_in_a_loop_runs_out_of_the_budget` searches about 3 GB, 14 s on a fast desktop), so the
|
||||
/// limit here only catches a program that never stops.
|
||||
fn runs_out_of_budget(body: &str) {
|
||||
pub(super) fn runs_out_of_budget(body: &str) {
|
||||
let r = read_within(&format!("0 0 10 10 rectfill {body}"), 120);
|
||||
assert_eq!(objects(&r.document).len(), 1, "{body}: {:?}", r.warnings);
|
||||
assert!(r.warnings.iter().any(|w| w.contains("the program runs too long")), "{body}: {:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// A large file that runs far more operations than a small one may, but only a few hundred for
|
||||
/// each object it draws, reads to its end: the operation budget grows with the program's length.
|
||||
#[test]
|
||||
fn a_large_file_with_many_objects_reads_to_its_end() {
|
||||
let n = 200_000;
|
||||
let mut body = String::from(
|
||||
"/sq { 4 dict begin /y exch def /x exch def 0 1 200 { pop } for \
|
||||
x y moveto x 1 add y lineto x y 1 add lineto closepath fill end } def\n",
|
||||
);
|
||||
for i in 0..n {
|
||||
body.push_str(&format!("{} {} sq\n", i % 100, i / 2000));
|
||||
}
|
||||
let r = read_within(&body, 300);
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
assert_eq!(objects(&r.document).len(), n);
|
||||
}
|
||||
|
||||
/// A loop of operators whose work grows with the path they make runs out of the operation budget
|
||||
/// (PLRM 3rd ed., chapter 8): `arc` and `arct` with a huge radius, which make thousands of curve
|
||||
/// segments, `strokepath` with a dash far finer than the line, dashes of length zero included, and
|
||||
|
||||
@@ -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::<String>();
|
||||
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<f64> = 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::<f64>() / 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"
|
||||
));
|
||||
}
|
||||
+273
-22
@@ -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<Vec2> {
|
||||
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<Vec2> {
|
||||
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<Vec2> {
|
||||
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<u8> {
|
||||
@@ -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<str>),
|
||||
}
|
||||
|
||||
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<f64> {
|
||||
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<Vec<Mapped>> {
|
||||
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<Vec2> {
|
||||
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<Option<(vectorcraft_geom::BezPath, f64)>> {
|
||||
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<str> {
|
||||
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();
|
||||
|
||||
@@ -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<Vec<u8>> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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<u16> = 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<u16> = 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)?;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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<Item = &str> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Rules from every `<style>` element, in source order.
|
||||
fn parse_sheet(css: &str, out: &mut Vec<Rule>) {
|
||||
// Drop comments outside strings.
|
||||
/// `css` without its `/* … */` comments (those inside quoted strings stay).
|
||||
fn strip_comments(css: &str) -> String {
|
||||
let mut text = String::with_capacity(css.len());
|
||||
let mut chars = css.chars().peekable();
|
||||
let mut quote = None;
|
||||
@@ -387,6 +387,12 @@ fn parse_sheet(css: &str, out: &mut Vec<Rule>) {
|
||||
text.push(c);
|
||||
}
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
/// Rules from every `<style>` element, in source order.
|
||||
fn parse_sheet(css: &str, out: &mut Vec<Rule>) {
|
||||
let text = strip_comments(css);
|
||||
let mut s = text.as_str();
|
||||
loop {
|
||||
s = s.trim_start();
|
||||
|
||||
@@ -333,3 +333,11 @@ fn not_selectors() {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decl_comments_are_stripped() {
|
||||
let d = |k: &str, v: &str| (k.to_string(), v.to_string(), false);
|
||||
assert_eq!(parse_decls("fill:red /* note */; stroke:blue"), [d("fill", "red"), d("stroke", "blue")]);
|
||||
assert_eq!(parse_decls("fill:red /* a;b */ ; stroke:blue"), [d("fill", "red"), d("stroke", "blue")]);
|
||||
assert_eq!(parse_decls("font-family:'A /* x */ B'"), [d("font-family", "'A /* x */ B'")]);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::rings::{MAX_PIECES, MAX_PLANE_POINTS, MAX_SOLID_POINTS, inset, left, rings, signed_area};
|
||||
use super::rings::{MAX_PIECES, MAX_PLANE_POINTS, MAX_SOLID_POINTS, inset, left, signed_area, solid_rings};
|
||||
use super::{Bevel, Extrude, Facet, FillRule, MAX_FACES, PathData, Point, Rect, Revolve, V3, View, eye_distance, perspective_scale};
|
||||
|
||||
/// Turns 3D points into document points and shades faces for one view.
|
||||
@@ -403,7 +403,7 @@ pub(crate) fn extrude_faces(path: &PathData, rule: FillRule, bounds: Rect, optio
|
||||
if o.depth <= 0.0 {
|
||||
return plane_faces(path, rule, bounds, view, mesh);
|
||||
}
|
||||
let rings = rings(path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?;
|
||||
let rings = solid_rings(path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?;
|
||||
let camera = Camera::new(view, bounds, o.depth);
|
||||
let mut b = Builder { camera: &camera, closed: o.cap, facets: vec![], mesh: mesh.then(Vec::new) };
|
||||
let steps = steps(&o);
|
||||
@@ -461,7 +461,7 @@ pub fn plane(path: &PathData, rule: FillRule, bounds: Rect, view: View) -> Resul
|
||||
/// [`plane`], or with `mesh` its face before projection instead of its facet.
|
||||
pub(crate) fn plane_faces(path: &PathData, rule: FillRule, bounds: Rect, view: View, mesh: bool) -> Result<Built, String> {
|
||||
check_bounds(bounds)?;
|
||||
let rings = rings(path, rule, bounds.width().max(bounds.height()), MAX_PLANE_POINTS)?;
|
||||
let rings = solid_rings(path, rule, bounds.width().max(bounds.height()), MAX_PLANE_POINTS)?;
|
||||
let camera = Camera::new(view, bounds, 0.0);
|
||||
let mut b = Builder { camera: &camera, closed: false, facets: vec![], mesh: mesh.then(Vec::new) };
|
||||
let contours: Vec<Vec<V3>> = rings.iter().map(|r| r.iter().map(|p| camera.place(*p, 0.0)).collect()).collect();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::build::{Builder, Built, Camera, Order, Turn, check_bounds, cuts, fit, newell, outward};
|
||||
use super::membrane::{Edges, MAX_MATERIAL_SEGMENTS, Membrane, cell_triangles, clip, filled, material};
|
||||
use super::rings::{MAX_SOLID_POINTS, rings, signed_area};
|
||||
use super::rings::{MAX_SOLID_POINTS, signed_area, solid_rings};
|
||||
use super::{Facet, FillRule, Inflate, MAX_FACES, PathData, Point, Rect, V3, Vec2, View};
|
||||
|
||||
/// A shape to inflate: its rings and the membrane under them on the grid of the bounds.
|
||||
@@ -18,7 +18,7 @@ struct Puff {
|
||||
|
||||
/// The rings of `path` and its membrane on the grid of `bounds`; `None` for a shape with no area.
|
||||
fn puff(path: &PathData, rule: FillRule, bounds: Rect, o: &Inflate) -> Result<Option<Puff>, String> {
|
||||
let rings = rings(path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?;
|
||||
let rings = solid_rings(path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?;
|
||||
if rings.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,175 @@ fn pieces(c: vectorcraft_geom::CubicBez, tolerance: f64) -> u32 {
|
||||
if n.is_finite() { (n as u32).clamp(1, 32) } else { 32 }
|
||||
}
|
||||
|
||||
/// Contours thinner than this on average (twice their area over their perimeter) are dropped when
|
||||
/// overlaps are resolved: the debris a boolean leaves where two curves coincide, far below any
|
||||
/// art. Pathfinder's default precision would drop real hairlines too.
|
||||
pub(super) const DEBRIS: f64 = 1e-6;
|
||||
|
||||
/// The rings of a solid's profile, as [`rings`], with contours that meet merged first. Type often
|
||||
/// draws one glyph as overlapping contours (a "k"'s arm across its stem), and [`rings`] tells a
|
||||
/// contour's side from one point of it, so a contour that meets another, or itself, must be cut
|
||||
/// where they meet. Most art has no such contours, and the boolean costs more than the rest of the
|
||||
/// solid, so it runs only when [`edges_meet`] finds some. Its answer already runs holes against
|
||||
/// their outlines, so it is only turned as a whole ([`wound`]), never judged a ring at a time.
|
||||
///
|
||||
/// The `limit` is checked on the profile as drawn, before the boolean, and again on the merged
|
||||
/// outline, which can have more points (bars crossing in a grid trace every hole between them):
|
||||
/// past it the shape is too complex, since its contours as drawn cross and can't be built as they
|
||||
/// are. A profile the boolean refuses is built from its contours as drawn, as it was before
|
||||
/// contours were merged. An empty answer is an answer: contours that cancel (a square drawn twice
|
||||
/// under even-odd), or that are all thinner than [`DEBRIS`], fill nothing, so nothing is built.
|
||||
pub(super) fn solid_rings(path: &PathData, rule: FillRule, size: f64, limit: usize) -> Result<Vec<Vec<Point>>, String> {
|
||||
let sampled = sample(path, size, limit)?;
|
||||
let closed = closed_straight(path);
|
||||
if !meets(&sampled, &closed) {
|
||||
return orient(sampled, rule);
|
||||
}
|
||||
match vectorcraft_pathops::try_unite_all_with(&[(&closed, rule)], DEBRIS) {
|
||||
Ok(merged) => sample(&merged, size, limit).map(wound),
|
||||
Err(_) => orient(sampled, rule),
|
||||
}
|
||||
}
|
||||
|
||||
/// Do the `sampled` rings of `closed` (the profile as the boolean takes it) meet, so that it must
|
||||
/// go through the boolean? Within twice the boolean's tolerance ([`touch`]): pathops first snaps
|
||||
/// anchors onto nearby heights (by less than the tolerance), then the sweep joins edges within it
|
||||
/// along the sweep line, so straight edges a little more than it apart can come out as one: at
|
||||
/// most sin θ + cos θ ≤ √2 times it, θ the edges' angle to the sweep line (about 1.4 at 45°,
|
||||
/// measured; the same when pathops turns the sweep a quarter turn, which it does for some art of
|
||||
/// 2,048 segments or more). Straight contours it might join are merged; those it leaves apart cost
|
||||
/// a boolean that builds the same shape. Curves are judged on their sampled outline (see
|
||||
/// [`sample`]), which can lie a little inside them (up to the sampling tolerance, or more for a
|
||||
/// long curve held to 32 pieces), so curves that touch or overlap by less than that can be judged
|
||||
/// apart and built as drawn.
|
||||
pub(super) fn meets(sampled: &[Vec<Point>], closed: &PathData) -> bool {
|
||||
edges_meet(sampled, 2.0 * touch(closed))
|
||||
}
|
||||
|
||||
/// `path` as the boolean should take it: each open subpath closed by a straight line, as [`sample`]
|
||||
/// and the renderer's fill close it (the boolean would close it along the end anchors' handles).
|
||||
/// Borrowed when nothing needs changing: every subpath closed, a single anchor (which the boolean
|
||||
/// skips), or open with no handle on the closing side of either end anchor (which the boolean
|
||||
/// already closes straight).
|
||||
pub(super) fn closed_straight(path: &PathData) -> std::borrow::Cow<'_, PathData> {
|
||||
// The closing segment (last anchor to first) as the boolean will draw it once closed; the
|
||||
// length test comes first, so a subpath of fewer than two anchors never reaches the index.
|
||||
let straight = |sp: &vectorcraft_geom::SubPath| sp.closed || sp.anchors.len() < 2 || sp.segment_is_line(sp.anchors.len() - 1);
|
||||
if path.subpaths.iter().all(straight) {
|
||||
return std::borrow::Cow::Borrowed(path);
|
||||
}
|
||||
let mut path = path.clone();
|
||||
for sp in path.subpaths.iter_mut().filter(|sp| !sp.closed) {
|
||||
if let Some(last) = sp.anchors.last_mut() {
|
||||
last.h_out = last.p;
|
||||
}
|
||||
if let Some(first) = sp.anchors.first_mut() {
|
||||
first.h_in = first.p;
|
||||
}
|
||||
sp.closed = true;
|
||||
}
|
||||
std::borrow::Cow::Owned(path)
|
||||
}
|
||||
|
||||
/// The boolean's rings turned so the filled side is on each one's left. Its holes already run
|
||||
/// against their outlines, so only the direction of the whole is chosen: the one whose rings
|
||||
/// enclose positive area in all.
|
||||
pub(super) fn wound(mut rings: Vec<Vec<Point>>) -> Vec<Vec<Point>> {
|
||||
rings.retain(|r| signed_area(r).abs() > 1e-9);
|
||||
if rings.iter().map(|r| signed_area(r)).sum::<f64>() < 0.0 {
|
||||
rings.iter_mut().for_each(|r| r.reverse());
|
||||
}
|
||||
rings
|
||||
}
|
||||
|
||||
/// The boolean's sweep tolerance for `path` as it is given to it (closed by [`closed_straight`]):
|
||||
/// 64 times the rounding of the largest coordinate it reads, every anchor and handle of each
|
||||
/// subpath of two anchors or more, and at least 1e-6 (pathops' `eps_for`). For a path [`sample`]
|
||||
/// accepts (every point finite).
|
||||
pub(super) fn touch(path: &PathData) -> f64 {
|
||||
let m = path
|
||||
.subpaths
|
||||
.iter()
|
||||
.filter(|sp| sp.anchors.len() >= 2)
|
||||
.flat_map(|sp| &sp.anchors)
|
||||
.flat_map(|a| [a.p, a.h_in, a.h_out])
|
||||
.fold(0.0f64, |m, q| m.max(q.x.abs()).max(q.y.abs()));
|
||||
(m * f64::EPSILON * 64.0).max(1e-6)
|
||||
}
|
||||
|
||||
/// Most pairs of edges [`edges_meet`] compares. Past it the answer is yes, and the boolean, with
|
||||
/// its own budget, decides.
|
||||
const MAX_EDGE_PAIRS: usize = 1 << 22;
|
||||
|
||||
/// Do any two edges of `rings` meet, within `touch` (at that distance or closer): a contour crossing, touching or running
|
||||
/// along another, or itself? Neighbouring edges of a ring share a corner and meet only where one
|
||||
/// folds back along the other. Edges are swept in order of their left ends, so only pairs whose
|
||||
/// bounds overlap across x are compared.
|
||||
pub(super) fn edges_meet(rings: &[Vec<Point>], touch: f64) -> bool {
|
||||
struct Edge {
|
||||
ring: usize,
|
||||
index: usize,
|
||||
a: Point,
|
||||
b: Point,
|
||||
x0: f64,
|
||||
x1: f64,
|
||||
}
|
||||
let mut edges: Vec<Edge> = rings
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(ring, r)| {
|
||||
r.iter().zip(r.iter().cycle().skip(1)).enumerate().map(move |(index, (a, b))| Edge {
|
||||
ring,
|
||||
index,
|
||||
a: *a,
|
||||
b: *b,
|
||||
x0: a.x.min(b.x),
|
||||
x1: a.x.max(b.x),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
edges.sort_by(|e, f| e.x0.total_cmp(&f.x0));
|
||||
let len = |ring: usize| rings.get(ring).map_or(0, Vec::len);
|
||||
let mut pairs = 0usize;
|
||||
for (i, e) in edges.iter().enumerate() {
|
||||
for f in edges.iter().skip(i + 1) {
|
||||
if f.x0 > e.x1 + touch {
|
||||
break;
|
||||
}
|
||||
pairs += 1;
|
||||
if pairs > MAX_EDGE_PAIRS {
|
||||
return true;
|
||||
}
|
||||
if e.a.y.min(e.b.y) > f.a.y.max(f.b.y) + touch || f.a.y.min(f.b.y) > e.a.y.max(e.b.y) + touch {
|
||||
continue;
|
||||
}
|
||||
let n = len(e.ring);
|
||||
let neighbours = e.ring == f.ring && ((e.index + 1) % n == f.index || (f.index + 1) % n == e.index);
|
||||
if neighbours {
|
||||
// They share a corner; they meet only if one runs back along the other.
|
||||
let (shared, p, q) = if e.b == f.a { (e.b, e.a, f.b) } else { (e.a, e.b, f.a) };
|
||||
if super::build::segment_distance(q, shared, p) <= touch || super::build::segment_distance(p, shared, q) <= touch {
|
||||
return true;
|
||||
}
|
||||
} else if segments_meet(e.a, e.b, f.a, f.b, touch) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Do segments `a`–`b` and `c`–`d` cross, or come within `touch` of each other?
|
||||
fn segments_meet(a: Point, b: Point, c: Point, d: Point, touch: f64) -> bool {
|
||||
let side = |p: Point, q: Point, r: Point| (q.x - p.x) * (r.y - p.y) - (q.y - p.y) * (r.x - p.x);
|
||||
let (d1, d2, d3, d4) = (side(c, d, a), side(c, d, b), side(a, b, c), side(a, b, d));
|
||||
if ((d1 > 0.0 && d2 < 0.0) || (d1 < 0.0 && d2 > 0.0)) && ((d3 > 0.0 && d4 < 0.0) || (d3 < 0.0 && d4 > 0.0)) {
|
||||
return true;
|
||||
}
|
||||
use super::build::segment_distance as dist;
|
||||
dist(a, c, d).min(dist(b, c, d)).min(dist(c, a, b)).min(dist(d, a, b)) <= touch
|
||||
}
|
||||
|
||||
/// The rings of a shape: closed polygons with the filled side on their left, (-dy, dx): outer
|
||||
/// outlines run one way and holes the other, so nonzero winding fills them as the art is. Curves
|
||||
/// are followed within a tolerance of 1/2000 of `size` (at least 0.05 pt), so small curves take
|
||||
@@ -28,11 +197,18 @@ fn pieces(c: vectorcraft_geom::CubicBez, tolerance: f64) -> u32 {
|
||||
/// is left out: a contour inside another that runs the same way doesn't cut a hole, as it would
|
||||
/// if nesting alone decided (#1724).
|
||||
pub(super) fn rings(path: &PathData, rule: FillRule, size: f64, limit: usize) -> Result<Vec<Vec<Point>>, String> {
|
||||
orient(sample(path, size, limit)?, rule)
|
||||
}
|
||||
|
||||
/// The closed polygons that follow `path`'s subpaths, as [`rings`] samples them, each running as
|
||||
/// its subpath does.
|
||||
pub(super) fn sample(path: &PathData, size: f64, limit: usize) -> Result<Vec<Vec<Point>>, String> {
|
||||
let tolerance = (size / 2000.0).max(0.05);
|
||||
let mut count = 0;
|
||||
let mut out = vec![];
|
||||
for sp in &path.subpaths {
|
||||
let segments = if sp.closed { sp.anchors.len() } else { sp.anchors.len().saturating_sub(1) };
|
||||
// A subpath of one anchor draws nothing, closed or not (the renderer and the boolean skip it).
|
||||
for sp in path.subpaths.iter().filter(|sp| sp.anchors.len() >= 2) {
|
||||
let segments = if sp.closed { sp.anchors.len() } else { sp.anchors.len() - 1 };
|
||||
if segments > limit {
|
||||
return Err("The shape is too complex for a 3D effect".into());
|
||||
}
|
||||
@@ -58,10 +234,21 @@ pub(super) fn rings(path: &PathData, rule: FillRule, size: f64, limit: usize) ->
|
||||
while ring.len() > 1 && ring.first().zip(ring.last()).is_some_and(|(a, b)| a.distance(*b) < 1e-9) {
|
||||
ring.pop();
|
||||
}
|
||||
if ring.len() >= 3 && signed_area(&ring).abs() > 1e-9 {
|
||||
// Rings with no area are kept here: a contour that crosses itself into lobes that cancel
|
||||
// (a bowtie) has none, and [`edges_meet`] must see it. [`orient`] leaves them out. A flat
|
||||
// ring, every point on one line, fills nothing under either rule and is left out now, so
|
||||
// that it doesn't send the profile through the boolean.
|
||||
if ring.len() >= 3 && !flat(&ring) {
|
||||
out.push(ring);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `out` (sampled rings) turned so the filled side is on each one's left, and under the nonzero
|
||||
/// `rule` without the rings that are no edge of the shape, as [`rings`] describes.
|
||||
fn orient(mut out: Vec<Vec<Point>>, rule: FillRule) -> Result<Vec<Vec<Point>>, String> {
|
||||
out.retain(|r| signed_area(r).abs() > 1e-9);
|
||||
// Which rings hold which: each ring against the others, its edges walked only where its
|
||||
// bounds hold the point, and within [`MAX_CONTAINMENT_EDGES`] edges and pairs in all (a pair
|
||||
// whose bounds miss the point costs a step too: many small rings are a square of pairs).
|
||||
@@ -118,6 +305,14 @@ pub(super) fn rings(path: &PathData, rule: FillRule, size: f64, limit: usize) ->
|
||||
/// the others, so many rings of many points each would otherwise take billions of steps.
|
||||
const MAX_CONTAINMENT_EDGES: usize = 50_000_000;
|
||||
|
||||
/// Does every point of `ring` lie on one line (within 1e-9, as [`sample`] merges points)?
|
||||
fn flat(ring: &[Point]) -> bool {
|
||||
let Some(&a) = ring.first() else { return true };
|
||||
let Some(&b) = ring.iter().max_by(|p, q| a.distance(**p).total_cmp(&a.distance(**q))) else { return true };
|
||||
let len = a.distance(b);
|
||||
len <= 1e-9 || ring.iter().all(|p| ((b - a).cross(*p - a) / len).abs() <= 1e-9)
|
||||
}
|
||||
|
||||
pub(super) fn signed_area(r: &[Point]) -> f64 {
|
||||
r.iter().zip(r.iter().cycle().skip(1)).take(r.len()).map(|(a, b)| a.x * b.y - b.x * a.y).sum::<f64>() / 2.0
|
||||
}
|
||||
|
||||
@@ -754,3 +754,407 @@ fn many_small_rings_far_apart_are_too_complex() {
|
||||
let e = rings(&p, FillRule::NonZero, r.width().max(r.height()), usize::MAX).unwrap_err();
|
||||
assert!(e.contains("too complex"), "{e}");
|
||||
}
|
||||
|
||||
/// Type often draws a glyph as contours that cross (a "k"'s arm across its stem, its leg across
|
||||
/// the arm). Each contour's side was told from its first point, so one starting inside another
|
||||
/// was dropped or cut as a hole, and the parts left were walled where they overlapped. The
|
||||
/// outline is the union the art fills, under either rule.
|
||||
#[test]
|
||||
fn crossing_contours_make_one_outline() {
|
||||
let rect = |r: Rect| {
|
||||
vectorcraft_geom::SubPath::polyline(&[Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)], true)
|
||||
};
|
||||
// The stem, an arm starting inside it, and a leg starting inside the arm.
|
||||
let k = PathData::new(vec![
|
||||
rect(Rect::new(0.0, 0.0, 20.0, 100.0)),
|
||||
rect(Rect::new(10.0, 40.0, 60.0, 60.0)),
|
||||
rect(Rect::new(50.0, 50.0, 70.0, 100.0)),
|
||||
]);
|
||||
let b = k.bounds().unwrap();
|
||||
for rule in [FillRule::NonZero, FillRule::EvenOdd] {
|
||||
let filled = vectorcraft_pathops::area(&k, rule);
|
||||
assert!(close(area(&plane(&k, rule, b, front()).unwrap()), filled, 1e-6), "{rule:?}: Rotate");
|
||||
let f = extrude(&k, rule, b, Extrude::default(), front()).unwrap();
|
||||
assert_eq!(f.len(), 1, "{rule:?}: straight on, only the front cap shows");
|
||||
assert!(close(area(&f), filled, 1e-6), "{rule:?}: Extrude's cap");
|
||||
let puffed = inflate(&k, rule, b, Inflate::default(), front()).unwrap();
|
||||
assert_eq!(extent(&puffed), b, "{rule:?}: Inflate covers the whole k");
|
||||
assert!(close(area(&puffed), filled, filled * 1e-3), "{rule:?}: Inflate's front, {} against {filled}", area(&puffed));
|
||||
}
|
||||
// The filled area is the union's: the stem, the arm past it and the leg past the arm.
|
||||
assert!(close(vectorcraft_pathops::area(&k, FillRule::NonZero), 20.0 * 100.0 + 40.0 * 20.0 + 20.0 * 50.0 - 10.0 * 10.0, 1e-6));
|
||||
}
|
||||
|
||||
/// Resolving overlaps keeps every contour that fills area, however narrow: a hairline beside a
|
||||
/// large shape is art. What a boolean leaves where two curves coincide is still dropped.
|
||||
#[test]
|
||||
fn resolving_overlaps_keeps_narrow_contours() {
|
||||
let rect = |r: Rect| {
|
||||
vectorcraft_geom::SubPath::polyline(&[Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)], true)
|
||||
};
|
||||
// A hairline beside a square, and two bars crossing elsewhere so the profile is resolved.
|
||||
let hairline = Rect::new(120.0, 0.0, 120.005, 50.0);
|
||||
let p = PathData::new(vec![
|
||||
rect(Rect::new(0.0, 0.0, 100.0, 100.0)),
|
||||
rect(hairline),
|
||||
rect(Rect::new(140.0, 40.0, 200.0, 60.0)),
|
||||
rect(Rect::new(160.0, 20.0, 180.0, 80.0)),
|
||||
]);
|
||||
let b = p.bounds().unwrap();
|
||||
let filled = |rule: FillRule| {
|
||||
let bars = if rule == FillRule::NonZero { 60.0 * 20.0 + 20.0 * 40.0 } else { 60.0 * 20.0 + 20.0 * 40.0 - 20.0 * 20.0 };
|
||||
100.0 * 100.0 + hairline.width() * hairline.height() + bars
|
||||
};
|
||||
for rule in [FillRule::NonZero, FillRule::EvenOdd] {
|
||||
assert!(close(area(&plane(&p, rule, b, front()).unwrap()), filled(rule), 1e-6), "{rule:?}: Rotate");
|
||||
assert!(close(area(&extrude(&p, rule, b, Extrude::default(), front()).unwrap()), filled(rule), 1e-6), "{rule:?}: Extrude's cap");
|
||||
}
|
||||
// A circle drawn twice, exactly and a hair off, is one outline: no sliver between the copies.
|
||||
let circle = |cx: f64, cy: f64, r: f64| {
|
||||
let (mut b, k) = (BezPath::new(), 0.5523 * r);
|
||||
b.move_to((cx + r, cy));
|
||||
b.curve_to((cx + r, cy + k), (cx + k, cy + r), (cx, cy + r));
|
||||
b.curve_to((cx - k, cy + r), (cx - r, cy + k), (cx - r, cy));
|
||||
b.curve_to((cx - r, cy - k), (cx - k, cy - r), (cx, cy - r));
|
||||
b.curve_to((cx + k, cy - r), (cx + r, cy - k), (cx + r, cy));
|
||||
b.close_path();
|
||||
b
|
||||
};
|
||||
for dx in [0.0, 1e-7] {
|
||||
let mut twice = circle(50.0, 50.0, 40.0);
|
||||
twice.extend(circle(50.0 + dx, 50.0, 40.0).iter());
|
||||
let p = PathData::from_bezpath(&twice);
|
||||
let f = plane(&p, FillRule::NonZero, p.bounds().unwrap(), front()).unwrap();
|
||||
assert_eq!(f.len(), 1, "{dx}");
|
||||
assert_eq!(f[0].contours.len(), 1, "{dx}");
|
||||
let once = PathData::from_bezpath(&circle(50.0, 50.0, 40.0));
|
||||
let one = area(&plane(&once, FillRule::NonZero, once.bounds().unwrap(), front()).unwrap());
|
||||
assert!(close(area(&f), one, one * 1e-6), "{dx}: the area of one circle, {} against {one}", area(&f));
|
||||
}
|
||||
}
|
||||
|
||||
/// Only a profile whose contours meet goes through the boolean: crossing, touching, running along
|
||||
/// another or folding back on itself. Separate and nested contours are taken as they are.
|
||||
#[test]
|
||||
fn contours_meet_when_they_cross_touch_or_fold_back() {
|
||||
let ring = |pts: &[(f64, f64)]| pts.iter().map(|&(x, y)| Point::new(x, y)).collect::<Vec<_>>();
|
||||
let square = |x: f64, y: f64, s: f64| ring(&[(x, y), (x + s, y), (x + s, y + s), (x, y + s)]);
|
||||
assert!(!rings::edges_meet(&[square(0.0, 0.0, 10.0), square(20.0, 0.0, 10.0)], 1e-6), "apart");
|
||||
assert!(!rings::edges_meet(&[square(0.0, 0.0, 10.0), square(3.0, 3.0, 4.0)], 1e-6), "nested, as an o's hole");
|
||||
assert!(rings::edges_meet(&[square(0.0, 0.0, 10.0), square(5.0, 5.0, 10.0)], 1e-6), "crossing");
|
||||
assert!(rings::edges_meet(&[square(0.0, 0.0, 10.0), square(10.0, 0.0, 10.0)], 1e-6), "sharing an edge");
|
||||
assert!(rings::edges_meet(&[square(0.0, 0.0, 10.0), square(10.0, 3.0, 4.0)], 1e-6), "along part of an edge");
|
||||
assert!(rings::edges_meet(&[square(0.0, 0.0, 10.0), square(10.0, 10.0, 4.0)], 1e-6), "at a corner");
|
||||
assert!(rings::edges_meet(&[square(0.0, 0.0, 10.0), square(10.0 + 1e-7, 0.0, 10.0)], 1e-6), "within the boolean's snapping");
|
||||
assert!(rings::edges_meet(&[ring(&[(0.0, 0.0), (10.0, 10.0), (10.0, 0.0), (0.0, 10.0)])], 1e-6), "a bowtie crossing itself");
|
||||
assert!(rings::edges_meet(&[ring(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (10.0, 5.0), (0.0, 10.0)])], 1e-6), "folding back on itself");
|
||||
// Squares sharing an edge inflate as one puff, not two with a seam between them.
|
||||
let shared = PathData::new(
|
||||
[Rect::new(0.0, 0.0, 50.0, 50.0), Rect::new(50.0, 0.0, 100.0, 50.0)]
|
||||
.iter()
|
||||
.map(|r| {
|
||||
vectorcraft_geom::SubPath::polyline(
|
||||
&[Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)],
|
||||
true,
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
let whole = PathData::from_bezpath(&{
|
||||
let mut b = BezPath::new();
|
||||
b.move_to((0.0, 0.0));
|
||||
b.line_to((100.0, 0.0));
|
||||
b.line_to((100.0, 50.0));
|
||||
b.line_to((0.0, 50.0));
|
||||
b.close_path();
|
||||
b
|
||||
});
|
||||
let b = whole.bounds().unwrap();
|
||||
let puff = |p: &PathData| inflate(p, FillRule::NonZero, b, Inflate::default(), front()).unwrap().len();
|
||||
assert_eq!(puff(&shared), puff(&whole));
|
||||
}
|
||||
|
||||
/// Flat contours fill nothing and build nothing, crossing or not; an invalid coordinate is refused,
|
||||
/// as it was; and a profile the boolean refuses is built from its contours as drawn.
|
||||
#[test]
|
||||
fn flat_invalid_and_refused_profiles() {
|
||||
let flat =
|
||||
PathData::new(vec![vectorcraft_geom::SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 10.0), Point::new(20.0, 20.0)], true)]);
|
||||
let crossing_flat = PathData::new(vec![
|
||||
vectorcraft_geom::SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 10.0), Point::new(20.0, 20.0)], true),
|
||||
vectorcraft_geom::SubPath::polyline(&[Point::new(0.0, 20.0), Point::new(10.0, 10.0), Point::new(20.0, 0.0)], true),
|
||||
]);
|
||||
let b = Rect::new(0.0, 0.0, 20.0, 20.0);
|
||||
for p in [&flat, &crossing_flat] {
|
||||
assert_eq!(area(&plane(p, FillRule::NonZero, b, front()).unwrap()), 0.0);
|
||||
assert_eq!(area(&extrude(p, FillRule::NonZero, b, Extrude::default(), front()).unwrap()), 0.0);
|
||||
}
|
||||
let mut bad = crossing_flat.clone();
|
||||
bad.subpaths[0].anchors[1].p = Point::new(f64::NAN, 10.0);
|
||||
assert_eq!(plane(&bad, FillRule::NonZero, b, front()).unwrap_err(), "The shape contains invalid coordinates");
|
||||
// A star of 3,001 points, each joined to the one 1,500 on: every edge spans the middle, so
|
||||
// past pathops' budget of segment pairs the boolean refuses it (`TooComplex`) without sweeping,
|
||||
// and the profile is turned as drawn.
|
||||
let n = 3001;
|
||||
let star: Vec<Point> = (0..n)
|
||||
.map(|i| {
|
||||
let a = std::f64::consts::TAU * ((i * 1500) % n) as f64 / n as f64;
|
||||
Point::new(100.0 + 100.0 * a.cos(), 100.0 + 100.0 * a.sin())
|
||||
})
|
||||
.collect();
|
||||
let star = PathData::new(vec![vectorcraft_geom::SubPath::polyline(&star, true)]);
|
||||
assert_eq!(
|
||||
vectorcraft_pathops::try_unite_all_with(&[(&star, FillRule::NonZero)], rings::DEBRIS),
|
||||
Err(vectorcraft_pathops::PathOpsError::TooComplex)
|
||||
);
|
||||
let b = star.bounds().unwrap();
|
||||
let drawn: f64 =
|
||||
rings::rings(&star, FillRule::NonZero, b.width().max(b.height()), usize::MAX).unwrap().iter().map(|r| rings::signed_area(r)).sum();
|
||||
assert!(drawn > 0.0);
|
||||
assert!(close(area(&plane(&star, FillRule::NonZero, b, front()).unwrap()), drawn, 1e-6), "built from its contours as drawn");
|
||||
}
|
||||
|
||||
/// A contour that crosses itself into lobes that cancel has no area of its own, yet fills both
|
||||
/// lobes; contours that cancel each other fill nothing. Both go through the boolean.
|
||||
#[test]
|
||||
fn self_crossing_and_cancelling_profiles_are_resolved() {
|
||||
let poly = |pts: &[(f64, f64)]| vectorcraft_geom::SubPath::polyline(&pts.iter().map(|&(x, y)| Point::new(x, y)).collect::<Vec<_>>(), true);
|
||||
let bowtie = PathData::new(vec![poly(&[(0.0, 0.0), (10.0, 10.0), (10.0, 0.0), (0.0, 10.0)])]);
|
||||
let b = bowtie.bounds().unwrap();
|
||||
for rule in [FillRule::NonZero, FillRule::EvenOdd] {
|
||||
assert!(close(area(&plane(&bowtie, rule, b, front()).unwrap()), 50.0, 1e-6), "{rule:?}: Rotate");
|
||||
assert!(close(area(&extrude(&bowtie, rule, b, Extrude::default(), front()).unwrap()), 50.0, 1e-6), "{rule:?}: Extrude's cap");
|
||||
assert!(inflate(&bowtie, rule, b, Inflate::default(), front()).is_ok_and(|f| close(area(&f), 50.0, 1e-6)), "{rule:?}: Inflate");
|
||||
}
|
||||
let square = poly(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]);
|
||||
let twice = PathData::new(vec![square.clone(), square]);
|
||||
let b = twice.bounds().unwrap();
|
||||
assert_eq!(area(&plane(&twice, FillRule::EvenOdd, b, front()).unwrap()), 0.0);
|
||||
assert_eq!(area(&extrude(&twice, FillRule::EvenOdd, b, Extrude::default(), front()).unwrap()), 0.0);
|
||||
assert!(inflate(&twice, FillRule::EvenOdd, b, Inflate::default(), front()).is_ok_and(|f| area(&f) == 0.0));
|
||||
}
|
||||
|
||||
/// The boolean's answer often starts a contour where it touches another. Its rings are turned as
|
||||
/// a whole, never judged from one point each, so touching parts are kept and holes stay open.
|
||||
#[test]
|
||||
fn touching_contours_keep_their_parts_and_holes() {
|
||||
let poly = |pts: &[(f64, f64)]| vectorcraft_geom::SubPath::polyline(&pts.iter().map(|&(x, y)| Point::new(x, y)).collect::<Vec<_>>(), true);
|
||||
// A triangle whose tip rests on a square's edge.
|
||||
let tip = PathData::new(vec![poly(&[(0.0, 10.0), (10.0, 10.0), (10.0, 20.0), (0.0, 20.0)]), poly(&[(0.0, 0.0), (10.0, 0.0), (5.0, 10.0)])]);
|
||||
let b = tip.bounds().unwrap();
|
||||
for rule in [FillRule::NonZero, FillRule::EvenOdd] {
|
||||
assert!(close(area(&plane(&tip, rule, b, front()).unwrap()), 150.0, 1e-6), "{rule:?}: Rotate");
|
||||
assert!(close(area(&extrude(&tip, rule, b, Extrude::default(), front()).unwrap()), 150.0, 1e-6), "{rule:?}: Extrude's cap");
|
||||
}
|
||||
// An even-odd triangular hole touching its square's edge.
|
||||
let hole =
|
||||
PathData::new(vec![poly(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)]), poly(&[(0.0, 50.0), (60.0, 0.0 + 20.0), (60.0, 80.0)])]);
|
||||
let b = hole.bounds().unwrap();
|
||||
assert!(close(area(&plane(&hole, FillRule::EvenOdd, b, front()).unwrap()), 10000.0 - 1800.0, 1e-6));
|
||||
}
|
||||
|
||||
/// An open subpath is closed by a straight line, whichever way the profile goes: the same as the
|
||||
/// renderer's fill, whether or not other contours meet.
|
||||
#[test]
|
||||
fn an_open_subpath_closes_straight_whether_or_not_contours_meet() {
|
||||
let open = vectorcraft_geom::SubPath {
|
||||
anchors: vec![
|
||||
vectorcraft_geom::Anchor {
|
||||
p: Point::new(0.0, 0.0),
|
||||
h_in: Point::new(0.0, -50.0),
|
||||
h_out: Point::new(0.0, 0.0),
|
||||
kind: vectorcraft_geom::AnchorKind::Corner,
|
||||
},
|
||||
vectorcraft_geom::Anchor::corner(Point::new(50.0, 100.0)),
|
||||
vectorcraft_geom::Anchor {
|
||||
p: Point::new(100.0, 0.0),
|
||||
h_in: Point::new(100.0, 0.0),
|
||||
h_out: Point::new(100.0, -50.0),
|
||||
kind: vectorcraft_geom::AnchorKind::Corner,
|
||||
},
|
||||
],
|
||||
closed: false,
|
||||
};
|
||||
let rect = |x0: f64, y0: f64, x1: f64, y1: f64| {
|
||||
vectorcraft_geom::SubPath::polyline(&[Point::new(x0, y0), Point::new(x1, y0), Point::new(x1, y1), Point::new(x0, y1)], true)
|
||||
};
|
||||
let alone = PathData::new(vec![open.clone()]);
|
||||
let with_bars = PathData::new(vec![open, rect(200.0, 40.0, 260.0, 60.0), rect(220.0, 20.0, 240.0, 80.0)]);
|
||||
let bars = 60.0 * 20.0 + 20.0 * 40.0;
|
||||
assert!(close(area(&plane(&alone, FillRule::NonZero, alone.bounds().unwrap(), front()).unwrap()), 5000.0, 1e-6));
|
||||
assert!(close(area(&plane(&with_bars, FillRule::NonZero, with_bars.bounds().unwrap(), front()).unwrap()), 5000.0 + bars, 1e-6));
|
||||
}
|
||||
|
||||
/// A flat contour beside separate ones fills nothing and doesn't send the profile through the
|
||||
/// boolean; contours far from the origin meet within the boolean's wider snapping; and a merged
|
||||
/// outline past the point limit is too complex.
|
||||
#[test]
|
||||
fn flat_far_and_too_large_merges() {
|
||||
let rect = |x0: f64, y0: f64, x1: f64, y1: f64| {
|
||||
vectorcraft_geom::SubPath::polyline(&[Point::new(x0, y0), Point::new(x1, y0), Point::new(x1, y1), Point::new(x0, y1)], true)
|
||||
};
|
||||
let squares: Vec<_> = (0..12).map(|i| rect(i as f64 * 20.0, 0.0, i as f64 * 20.0 + 10.0, 10.0)).collect();
|
||||
let line = vectorcraft_geom::SubPath::polyline(&[Point::new(0.0, 300.0), Point::new(50.0, 300.0), Point::new(100.0, 300.0)], false);
|
||||
let with_line = PathData::new(squares.iter().cloned().chain([line]).collect());
|
||||
assert!(!rings::meets(&rings::sample(&with_line, 300.0, usize::MAX).unwrap(), &rings::closed_straight(&with_line)));
|
||||
assert!(close(area(&plane(&with_line, FillRule::NonZero, with_line.bounds().unwrap(), front()).unwrap()), 1200.0, 1e-6));
|
||||
// Far from the origin the boolean snaps wider than 1e-6 (1.4e-6 at 9.9e7), and so does the
|
||||
// test for meeting: squares 1.35e-6 apart there are one outline, one cap of both areas.
|
||||
let far = PathData::new(vec![rect(9.9e7, 0.0, 9.9e7 + 10.0, 10.0), rect(9.9e7 + 10.0 + 1.35e-6, 0.0, 9.9e7 + 20.0, 10.0)]);
|
||||
let cap = plane(&far, FillRule::NonZero, far.bounds().unwrap(), front()).unwrap();
|
||||
assert_eq!(cap.iter().map(|f| f.contours.len()).sum::<usize>(), 1, "joined into one outline");
|
||||
assert!(close(area(&cap), 200.0, 1e-3), "joined, not lost");
|
||||
// Handles count too: a lobe whose handles reach 1.3e8 widens the boolean's snapping to 1.85e-6.
|
||||
let lobe = vectorcraft_geom::SubPath {
|
||||
anchors: vec![
|
||||
vectorcraft_geom::Anchor {
|
||||
p: Point::new(0.0, 1000.0),
|
||||
h_in: Point::new(0.0, 1000.0),
|
||||
h_out: Point::new(1.3e8, 1000.0),
|
||||
kind: vectorcraft_geom::AnchorKind::Corner,
|
||||
},
|
||||
vectorcraft_geom::Anchor {
|
||||
p: Point::new(0.0, 1100.0),
|
||||
h_in: Point::new(1.3e8, 1100.0),
|
||||
h_out: Point::new(0.0, 1100.0),
|
||||
kind: vectorcraft_geom::AnchorKind::Corner,
|
||||
},
|
||||
],
|
||||
closed: true,
|
||||
};
|
||||
let wide = PathData::new(vec![rect(9.9e7, 0.0, 9.9e7 + 10.0, 10.0), rect(9.9e7 + 10.0 + 1.6e-6, 0.0, 9.9e7 + 20.0, 10.0), lobe]);
|
||||
let b = wide.bounds().unwrap();
|
||||
let joined = plane(&wide, FillRule::NonZero, b, front()).unwrap();
|
||||
assert_eq!(joined.iter().map(|f| f.contours.len()).sum::<usize>(), 2, "the squares joined, the lobe apart");
|
||||
// 24 bars each way: 192 points as drawn, some 2,400 once every hole between them is traced:
|
||||
// past Extrude's and Inflate's 2,048 the shape is too complex (its contours as drawn cross, so
|
||||
// they can't be built as they are), and Rotate's 65,536 takes it.
|
||||
let mut bars = vec![];
|
||||
for i in 0..24 {
|
||||
let x = i as f64 * 10.0;
|
||||
bars.push(rect(x, 0.0, x + 4.0, 240.0));
|
||||
bars.push(rect(0.0, x, 240.0, x + 4.0));
|
||||
}
|
||||
let grid = PathData::new(bars);
|
||||
let b = grid.bounds().unwrap();
|
||||
for rule in [FillRule::NonZero, FillRule::EvenOdd] {
|
||||
assert_eq!(extrude(&grid, rule, b, Extrude::default(), front()).unwrap_err(), "The shape is too complex for a 3D effect", "{rule:?}");
|
||||
assert_eq!(inflate(&grid, rule, b, Inflate::default(), front()).unwrap_err(), "The shape is too complex for a 3D effect", "{rule:?}");
|
||||
assert!(close(area(&plane(&grid, rule, b, front()).unwrap()), vectorcraft_pathops::area(&grid, rule), 1e-6), "{rule:?}: Rotate");
|
||||
}
|
||||
}
|
||||
|
||||
/// A profile goes through the boolean when its contours come within twice the boolean's tolerance
|
||||
/// of the path it is given: pathops snaps anchors onto nearby heights before sweeping, then the
|
||||
/// sweep joins edges within its tolerance, so contours a little more than it apart can come out
|
||||
/// as one.
|
||||
#[test]
|
||||
fn meets_within_twice_the_boolean_tolerance() {
|
||||
let rect = |x0: f64, y0: f64, x1: f64, y1: f64| {
|
||||
vectorcraft_geom::SubPath::polyline(&[Point::new(x0, y0), Point::new(x1, y0), Point::new(x1, y1), Point::new(x0, y1)], true)
|
||||
};
|
||||
let meets = |p: &PathData| rings::meets(&rings::sample(p, 300.0, usize::MAX).unwrap(), &rings::closed_straight(p));
|
||||
// Two triangles whose facing 45° edges are 1.4e-6 apart, the second's corners 0.99e-6 lower on
|
||||
// the page (greater y) than the first's: snapped onto the first's heights, they are joined.
|
||||
let d = 0.99e-6;
|
||||
let snapped = PathData::new(vec![
|
||||
vectorcraft_geom::SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(0.0, 10.0)], true),
|
||||
vectorcraft_geom::SubPath::polyline(&[Point::new(10.0 + d, d), Point::new(10.0 + d, 10.0 + d), Point::new(d, 10.0 + d)], true),
|
||||
]);
|
||||
assert!(meets(&snapped));
|
||||
let cap = plane(&snapped, FillRule::NonZero, snapped.bounds().unwrap(), front()).unwrap();
|
||||
assert_eq!(cap.iter().map(|f| f.contours.len()).sum::<usize>(), 1, "joined into one outline");
|
||||
assert!(close(area(&cap), 100.0, 1e-3), "joined, not lost");
|
||||
// At the same heights the boolean leaves the triangles apart, and the shape it builds is the
|
||||
// one drawn: both triangles, two outlines.
|
||||
let tri = |g: f64| {
|
||||
PathData::new(vec![
|
||||
vectorcraft_geom::SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(0.0, 10.0)], true),
|
||||
vectorcraft_geom::SubPath::polyline(&[Point::new(10.0 + g, 0.0), Point::new(10.0 + g, 10.0), Point::new(g, 10.0)], true),
|
||||
])
|
||||
};
|
||||
let apart = tri(1.4e-6 * std::f64::consts::SQRT_2);
|
||||
assert!(meets(&apart));
|
||||
assert_eq!(vectorcraft_pathops::try_unite_all_with(&[(&apart, FillRule::NonZero)], rings::DEBRIS).map(|p| p.subpaths.len()), Ok(2));
|
||||
let cap = plane(&apart, FillRule::NonZero, apart.bounds().unwrap(), front()).unwrap();
|
||||
assert_eq!(cap.iter().map(|f| f.contours.len()).sum::<usize>(), 2);
|
||||
assert!(close(area(&cap), 100.0, 1e-3), "both kept");
|
||||
// Twice the tolerance, not less: 45° edges 1.9e-6 apart still meet.
|
||||
assert!(meets(&tri(1.9e-6 * std::f64::consts::SQRT_2)));
|
||||
// And pathops joins nothing past it: the worst case, 2.05e-6 apart as `meets` measures them,
|
||||
// with the second triangle's corners just under the tolerance lower so that the snap brings it
|
||||
// closest, stays two outlines.
|
||||
let lift = 0.999e-6;
|
||||
let g = 2.05e-6 * std::f64::consts::SQRT_2 - lift;
|
||||
let past = PathData::new(vec![
|
||||
vectorcraft_geom::SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(0.0, 10.0)], true),
|
||||
vectorcraft_geom::SubPath::polyline(&[Point::new(10.0 + g, lift), Point::new(10.0 + g, 10.0 + lift), Point::new(g, 10.0 + lift)], true),
|
||||
]);
|
||||
assert!(!meets(&past));
|
||||
assert_eq!(vectorcraft_pathops::try_unite_all_with(&[(&past, FillRule::NonZero)], rings::DEBRIS).map(|p| p.subpaths.len()), Ok(2));
|
||||
// Near the origin, contours a few millionths apart stay apart and skip the boolean.
|
||||
assert!(!meets(&PathData::new(vec![rect(0.0, 0.0, 10.0, 10.0), rect(10.0 + 2.2e-6, 0.0, 20.0, 10.0)])));
|
||||
// What the boolean is given decides the tolerance: an open subpath's end handles (closed
|
||||
// straight) and a one-anchor subpath (skipped) don't widen it.
|
||||
let ends = vectorcraft_geom::SubPath {
|
||||
anchors: vec![
|
||||
vectorcraft_geom::Anchor {
|
||||
p: Point::new(0.0, 0.0),
|
||||
h_in: Point::new(1e9, 0.0),
|
||||
h_out: Point::new(0.0, 0.0),
|
||||
kind: vectorcraft_geom::AnchorKind::Corner,
|
||||
},
|
||||
vectorcraft_geom::Anchor {
|
||||
p: Point::new(10.0, 0.0),
|
||||
h_in: Point::new(10.0, 0.0),
|
||||
h_out: Point::new(1e9, 0.0),
|
||||
kind: vectorcraft_geom::AnchorKind::Corner,
|
||||
},
|
||||
],
|
||||
closed: false,
|
||||
};
|
||||
let speck = vectorcraft_geom::SubPath { anchors: vec![vectorcraft_geom::Anchor::corner(Point::new(1e9, 1e9))], closed: true };
|
||||
let quiet = PathData::new(vec![ends, speck]);
|
||||
assert_eq!(rings::touch(&rings::closed_straight(&quiet)), 1e-6);
|
||||
// A closing-side handle at either end alone is enough to close the subpath straight.
|
||||
for kept in [0, 1] {
|
||||
let mut one = quiet.subpaths[0].clone();
|
||||
one.anchors[0].h_in = if kept == 0 { Point::new(1e9, 0.0) } else { one.anchors[0].p };
|
||||
one.anchors[1].h_out = if kept == 1 { Point::new(1e9, 0.0) } else { one.anchors[1].p };
|
||||
assert_eq!(rings::touch(&rings::closed_straight(&PathData::new(vec![one]))), 1e-6, "handle kept at anchor {kept}");
|
||||
}
|
||||
// Beside two squares 3e-6 apart near the origin (farther apart than twice 1e-6), they don't
|
||||
// send the profile through the boolean; read unclosed, the end handles would.
|
||||
let mut quiet_squares = quiet.clone();
|
||||
quiet_squares.subpaths.extend([rect(0.0, 100.0, 10.0, 110.0), rect(10.0 + 3e-6, 100.0, 20.0, 110.0)]);
|
||||
assert!(!meets(&quiet_squares));
|
||||
}
|
||||
|
||||
/// The boolean's rings are turned as a whole so the filled side is on their left: holes stay
|
||||
/// against their outlines, and a profile wound the other way round is turned over.
|
||||
#[test]
|
||||
fn the_merged_rings_are_turned_as_a_whole() {
|
||||
let outline = vec![Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(10.0, 10.0), Point::new(0.0, 10.0)];
|
||||
let hole = vec![Point::new(2.0, 2.0), Point::new(2.0, 8.0), Point::new(8.0, 8.0), Point::new(8.0, 2.0)];
|
||||
let turned = rings::wound(vec![outline.iter().rev().copied().collect(), hole.iter().rev().copied().collect()]);
|
||||
assert!(rings::signed_area(&turned[0]) > 0.0 && rings::signed_area(&turned[1]) < 0.0);
|
||||
let kept = rings::wound(vec![outline.clone(), hole.clone()]);
|
||||
assert_eq!(kept, vec![outline, hole]);
|
||||
}
|
||||
|
||||
/// A subpath of one anchor draws nothing, so it builds nothing, beside other art or not.
|
||||
#[test]
|
||||
fn a_one_anchor_subpath_builds_nothing() {
|
||||
let loop_ = vectorcraft_geom::SubPath {
|
||||
anchors: vec![vectorcraft_geom::Anchor {
|
||||
p: Point::new(0.0, 0.0),
|
||||
h_in: Point::new(-20.0, 30.0),
|
||||
h_out: Point::new(20.0, 30.0),
|
||||
kind: vectorcraft_geom::AnchorKind::Corner,
|
||||
}],
|
||||
closed: true,
|
||||
};
|
||||
let p = PathData::new(vec![loop_]);
|
||||
let b = Rect::new(-20.0, 0.0, 20.0, 30.0);
|
||||
assert_eq!(area(&plane(&p, FillRule::NonZero, b, front()).unwrap()), 0.0);
|
||||
}
|
||||
|
||||
@@ -188,6 +188,10 @@ pub struct ToolContext<'a> {
|
||||
/// Clipboard Handling → When pasting text: Keep Plain Text. Text pasted while typing takes
|
||||
/// the style at the caret, even the text the Type tool copied with its formatting.
|
||||
pub paste_plain_text: bool,
|
||||
/// The text given to [`crate::Tool::text_input`] was typed on the keyboard (a key or an IME
|
||||
/// commit), not inserted from the Glyphs panel or a paste: a lone typed `”` may then take the
|
||||
/// document's opening quote ([`vectorcraft_doc::Quotes::typed_as_straight`]).
|
||||
pub typed_from_keyboard: bool,
|
||||
/// View → Hide Slices: the Slice Selection tool can't pick hidden slices.
|
||||
pub slices_hidden: bool,
|
||||
/// View → Lock Slices: the Slice Selection tool leaves locked slices alone.
|
||||
|
||||
@@ -257,7 +257,7 @@ pub struct CornerDrag {
|
||||
}
|
||||
|
||||
/// Direct Selection: the drawn anchors of a widget's corner, which a click on the widget selects
|
||||
/// (Shift: toggles), as a click on the corner's anchor does.
|
||||
/// (Shift: toggles, unless every corner is selected), as a click on the corner's anchor does.
|
||||
#[derive(Clone, Debug)]
|
||||
struct CornerClick {
|
||||
id: NodeId,
|
||||
@@ -282,7 +282,13 @@ impl CornerDrag {
|
||||
let path = widgets.paths.get(widget.0)?;
|
||||
let corner = path.widgets.get(widget.1)?.corner.index;
|
||||
let lc = cx.doc.node(path.id).and_then(LiveCorners::of)?;
|
||||
Some(CornerClick { id: path.id, anchors: lc.anchors_of(&[corner].into()).into_iter().collect(), shift: ev.mods.shift })
|
||||
// While every corner is selected (the path selected whole), a Shift-click picks
|
||||
// this corner alone, as the first click of "click one widget, then Shift-click the
|
||||
// others" does: toggling it would deselect it and leave the rest (#5188). Selected
|
||||
// anchors that are no corner show every widget too, but select no corner: there a
|
||||
// Shift-click adds the corner.
|
||||
let every = cx.selection.partial(path.id).is_none_or(|anchors| lc.all().is_subset(&lc.corners_of(anchors)));
|
||||
Some(CornerClick { id: path.id, anchors: lc.anchors_of(&[corner].into()).into_iter().collect(), shift: ev.mods.shift && !every })
|
||||
})
|
||||
.flatten();
|
||||
let outlines = widgets
|
||||
|
||||
@@ -196,7 +196,9 @@ fn drag_follows_a_rotated_shape_and_a_click_does_nothing() {
|
||||
|
||||
/// Direct Selection: a click on a widget (no drag) selects its corner, as a click on the
|
||||
/// corner's anchor does, so the next drag on that widget rounds that corner alone; Shift
|
||||
/// toggles the corner. The Selection tool's click still does nothing.
|
||||
/// toggles the corner, except while every corner is selected (#5188, see
|
||||
/// `a_shift_click_on_a_widget_of_a_whole_path_picks_its_corner`). The Selection tool's click
|
||||
/// still does nothing.
|
||||
#[test]
|
||||
fn a_click_on_a_widget_with_direct_selection_selects_its_corner() {
|
||||
let (d, id) = live_rect(10.0, Affine::translate((100.0, 100.0)));
|
||||
@@ -211,20 +213,80 @@ fn a_click_on_a_widget_with_direct_selection_selects_its_corner() {
|
||||
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, w.x, w.y)).is_empty());
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, w.x, w.y));
|
||||
assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": anchors, "mode": "set"}))]);
|
||||
let shift = Mods { shift: true, ..Mods::default() };
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, w.x, w.y).with_mods(shift));
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, w.x, w.y).with_mods(shift));
|
||||
assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": anchors, "mode": "toggle"}))]);
|
||||
// With that corner selected, its widget alone shows, and a drag rounds it alone
|
||||
// (`direct_selected_corners_show_their_widgets_alone_and_round_alone`).
|
||||
let mut part = selected(id);
|
||||
part.anchors.insert(id, anchors.iter().copied().collect());
|
||||
assert_eq!(CornerWidgets::of(&d, &part, 1.0, true).unwrap().corners(), [2]);
|
||||
// A Shift-click on it then deselects it.
|
||||
let shift = Mods { shift: true, ..Mods::default() };
|
||||
let one = crate::testutil::cx(&d, &part, &p);
|
||||
t.pointer(&one, &PointerEvent::new(PointerKind::Down, w.x, w.y).with_mods(shift));
|
||||
let a = t.pointer(&one, &PointerEvent::new(PointerKind::Up, w.x, w.y).with_mods(shift));
|
||||
assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": anchors, "mode": "toggle"}))]);
|
||||
let mut t = crate::create("selection");
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, w.x, w.y));
|
||||
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, w.x, w.y)).is_empty());
|
||||
}
|
||||
|
||||
/// #5188: while every corner of a path is selected (the path selected whole), a Shift-click on a
|
||||
/// widget picks that corner alone, as the first click of "click one widget, then Shift-click the
|
||||
/// others" does, rather than deselecting it and leaving the others. Once some corners are picked,
|
||||
/// Shift-clicks toggle.
|
||||
#[test]
|
||||
fn a_shift_click_on_a_widget_of_a_whole_path_picks_its_corner() {
|
||||
let (d, id) = live_rect(10.0, Affine::translate((100.0, 100.0)));
|
||||
let p = paint();
|
||||
let lc = LiveCorners::of(d.node(id).unwrap()).unwrap();
|
||||
let anchors = |k: usize| -> Vec<(usize, usize)> { lc.anchors_of(&[k].into()).into_iter().collect() };
|
||||
let shift = Mods { shift: true, ..Mods::default() };
|
||||
let click = |s: &Selection, at: Point| {
|
||||
let cx = crate::testutil::cx(&d, s, &p);
|
||||
let mut t = crate::create("directSelection");
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y).with_mods(shift));
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, at.x, at.y).with_mods(shift))
|
||||
};
|
||||
let pick = |k: usize, mode: &str| vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": anchors(k), "mode": mode}))];
|
||||
// Selected whole: every widget shows; a Shift-click on one picks its corner alone.
|
||||
let whole = selected(id);
|
||||
let w = pts(&CornerWidgets::of(&d, &whole, 1.0, true).unwrap());
|
||||
assert_eq!(w.len(), 4);
|
||||
assert_eq!(click(&whole, w[2]), pick(2, "set"));
|
||||
// Every anchor direct-selected is every corner selected too.
|
||||
let mut every = selected(id);
|
||||
every.anchors.insert(id, (0..4).flat_map(anchors).collect());
|
||||
assert_eq!(click(&every, w[1]), pick(1, "set"));
|
||||
// Two corners picked: a Shift-click on either widget deselects that corner.
|
||||
let mut two = selected(id);
|
||||
two.anchors.insert(id, [0, 2].into_iter().flat_map(anchors).collect());
|
||||
let shown = pts(&CornerWidgets::of(&d, &two, 1.0, true).unwrap());
|
||||
assert_eq!(shown, [w[0], w[2]]);
|
||||
assert_eq!(click(&two, w[0]), pick(0, "toggle"));
|
||||
}
|
||||
|
||||
/// #5188: an anchor selected that is no corner (here half way along a side) shows every widget
|
||||
/// but selects no corner, so a Shift-click on a widget adds its corner to that selection.
|
||||
#[test]
|
||||
fn a_shift_click_on_a_widget_adds_to_anchors_that_are_no_corner() {
|
||||
let corners = [(100.0, 100.0), (150.0, 100.0), (200.0, 100.0), (200.0, 200.0), (100.0, 200.0)].map(|(x, y)| Point::new(x, y));
|
||||
let (d, id) = doc_with(PathData::single(vectorcraft_geom::SubPath::polyline(&corners, true)), None);
|
||||
let p = paint();
|
||||
let mut s = selected(id);
|
||||
s.anchors.insert(id, [(0, 1)].into());
|
||||
let cx = cx(&d, &s, &p);
|
||||
let widgets = CornerWidgets::for_tool(&cx, true).unwrap();
|
||||
assert_eq!(widgets.corners().len(), 4, "no corner selected: every widget shows");
|
||||
let at = pts(&widgets)[0];
|
||||
let lc = LiveCorners::of(d.node(id).unwrap()).unwrap();
|
||||
let corner = *lc.all().iter().next().unwrap();
|
||||
let anchors: Vec<(usize, usize)> = lc.anchors_of(&[corner].into()).into_iter().collect();
|
||||
let shift = Mods { shift: true, ..Mods::default() };
|
||||
let mut t = crate::create("directSelection");
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y).with_mods(shift));
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, at.x, at.y).with_mods(shift));
|
||||
assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": anchors, "mode": "toggle"}))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_selected_corners_show_their_widgets_alone_and_round_alone() {
|
||||
let (d, id) = live_rect(0.0, Affine::translate((100.0, 100.0)));
|
||||
|
||||
@@ -511,6 +511,7 @@ pub(crate) mod testutil {
|
||||
unit: Unit::Points,
|
||||
stroke_unit: Unit::Points,
|
||||
paste_plain_text: false,
|
||||
typed_from_keyboard: false,
|
||||
slices_hidden: false,
|
||||
slices_locked: false,
|
||||
auto_add_delete: true,
|
||||
|
||||
@@ -36,7 +36,7 @@ use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::hit::hit_test;
|
||||
use vectorcraft_doc::{NodeId, NodeKind, TextKind, TextObject, TextRun};
|
||||
use vectorcraft_doc::{NodeId, NodeKind, Quotes, TextKind, TextObject, TextRun};
|
||||
use vectorcraft_geom::{BezPath, Point, Rect, Shape};
|
||||
use vectorcraft_text::{FontDb, TextLayout, edit};
|
||||
|
||||
@@ -328,11 +328,26 @@ impl TypeTool {
|
||||
return None;
|
||||
}
|
||||
// Use Typographer's Quotes (Document Setup): a typed straight quote becomes the document's
|
||||
// opening or closing quote, by the character before it.
|
||||
// opening or closing quote, by the character before it. A `”` typed alone (the macOS Serbian
|
||||
// Cyrillic layout's only double quote) becomes the opening quote where one opens, and stays
|
||||
// `”` where one closes.
|
||||
let setup = &cx.doc.setup;
|
||||
if setup.typographers_quotes && matches!(s.as_str(), "\"" | "'") {
|
||||
let mut prev = self.char_before(cx, self.caret.min(self.anchor));
|
||||
return Some(setup.quotes.apply(&s, &mut prev));
|
||||
if setup.typographers_quotes {
|
||||
let mut chars = s.chars();
|
||||
if let (Some(c), None) = (chars.next(), chars.next()) {
|
||||
if matches!(c, '"' | '\'') {
|
||||
let mut prev = self.char_before(cx, self.caret.min(self.anchor));
|
||||
return Some(setup.quotes.apply(&s, &mut prev));
|
||||
}
|
||||
if cx.typed_from_keyboard
|
||||
&& let Some(q) = Quotes::typed_as_straight(c)
|
||||
{
|
||||
let mut prev = self.char_before(cx, self.caret.min(self.anchor));
|
||||
if setup.quotes.opens_after(prev) {
|
||||
return Some(setup.quotes.apply(&q.to_string(), &mut prev));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(s)
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@ impl Tool for TypeTool {
|
||||
// commit this check still sees the pre-edit at the caret, while `text_input` replaces it
|
||||
// from its start; with a custom pair whose opening quote is straight, that can look
|
||||
// verbatim here and then be rewritten. Never claim verbatim for one.
|
||||
if cx.doc.setup.typographers_quotes && matches!(s, "\"" | "'") {
|
||||
if cx.doc.setup.typographers_quotes && (matches!(s, "\"" | "'") || (cx.typed_from_keyboard && is_one_typed_curly_quote(s))) {
|
||||
return false;
|
||||
}
|
||||
let Some(normalized) = self.input_text(cx, s) else { return false };
|
||||
@@ -716,3 +716,10 @@ impl Tool for TypeTool {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `s` one curly quote that Typographer's Quotes may set like a straight one
|
||||
/// ([`vectorcraft_doc::Quotes::typed_as_straight`]: a lone `”`)?
|
||||
fn is_one_typed_curly_quote(s: &str) -> bool {
|
||||
let mut chars = s.chars();
|
||||
matches!((chars.next(), chars.next()), (Some(c), None) if vectorcraft_doc::Quotes::typed_as_straight(c).is_some())
|
||||
}
|
||||
|
||||
@@ -89,6 +89,7 @@ impl VectorcraftApp {
|
||||
view_moved: None,
|
||||
hover_cursor: None,
|
||||
presented: None,
|
||||
opening_fit: None,
|
||||
panes: Default::default(),
|
||||
windows: crate::doc_windows::Windows::new(known),
|
||||
},
|
||||
|
||||
@@ -65,6 +65,10 @@ pub struct CanvasCache {
|
||||
/// In Presentation Mode, the canvas rect the active artboard was last fitted to: it is fitted
|
||||
/// again when the rect changes (the window going full screen).
|
||||
pub presented: Option<egui::Rect>,
|
||||
/// The canvas a document's opening view was fitted to, with the view the fit gave. The window's
|
||||
/// layout can still move the canvas's edges over the next frames, so until the view is moved or
|
||||
/// the user acts, the opening view is fitted again when the canvas changes ([`canvas::show`]).
|
||||
pub opening_fit: Option<canvas::ViewKey>,
|
||||
/// Window › Arrange › Tile: the renders the panes of the other documents draw.
|
||||
pub panes: canvas::Panes,
|
||||
/// Window › Arrange › Float in Window: what the floating document windows keep between frames.
|
||||
|
||||
@@ -186,6 +186,36 @@ fn view_rects(app: &VectorcraftApp, outer: egui::Rect, ppp: f32) -> (egui::Rect,
|
||||
(full, rect.round_to_pixels(ppp))
|
||||
}
|
||||
|
||||
/// Whether the user pressed a button or a key, typed, scrolled or zoomed in this frame: the
|
||||
/// opening fit stops following the canvas then ([`crate::caches::CanvasCache::opening_fit`]).
|
||||
fn acted(i: &egui::InputState) -> bool {
|
||||
i.pointer.any_pressed()
|
||||
|| i.events.iter().any(|e| {
|
||||
matches!(
|
||||
e,
|
||||
egui::Event::Key { pressed: true, .. }
|
||||
| egui::Event::Text(_)
|
||||
| egui::Event::MouseWheel { .. }
|
||||
| egui::Event::Zoom(_)
|
||||
| egui::Event::Touch { .. }
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// The view `v` shows in the canvas `rect`, as the opening fit records it.
|
||||
fn view_key(v: &View, rect: egui::Rect) -> ViewKey {
|
||||
ViewKey { rect, zoom: v.zoom, center: v.center, rotation: v.rotation }
|
||||
}
|
||||
|
||||
/// Whether the opening fit still follows the canvas `rect` (the view is unfitted, or unmoved since
|
||||
/// the fit and the user hasn't acted), and whether the canvas moved under it this frame, so the view
|
||||
/// is fitted again.
|
||||
fn opening_fit_follows(app: &VectorcraftApp, ui: &Ui, rect: egui::Rect, fitted: bool) -> (bool, bool) {
|
||||
let following = !fitted || (!ui.input(acted) && app.canvas.opening_fit.zip(app.view()).is_some_and(|(was, v)| view_key(v, was.rect) == was));
|
||||
let settling = fitted && following && app.canvas.opening_fit.is_some_and(|was| was.rect != rect);
|
||||
(following, settling)
|
||||
}
|
||||
|
||||
/// Paint the document canvas, complete or cached artwork, navigation and tool overlays.
|
||||
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
@@ -225,13 +255,19 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let (full, rect) = view_rects(app, outer, ui.ctx().pixels_per_point());
|
||||
app.canvas_rect = Some(rect);
|
||||
let fitted = app.view().is_some_and(|v| v.fitted);
|
||||
// A document's opening view is fitted to the canvas of the first frame that shows it, and the
|
||||
// window's layout can still move the canvas's edges over the next frames. Until the view is
|
||||
// moved or the user acts, the opening fit follows the canvas, so that the document opens at the
|
||||
// zoom of the settled window.
|
||||
let (following, settling) = opening_fit_follows(app, ui, rect, fitted);
|
||||
// Presentation Mode shows the active artboard on the whole screen and nothing but the art:
|
||||
// no selection, guides, grids or artboard edges (#4365).
|
||||
let presenting = app.ui.screen_mode == 3;
|
||||
if !fitted || (presenting && app.canvas.presented != Some(rect)) {
|
||||
if !fitted || settling || (presenting && app.canvas.presented != Some(rect)) {
|
||||
fit(app, "view.fitArtboard");
|
||||
}
|
||||
app.canvas.presented = presenting.then_some(rect);
|
||||
app.canvas.opening_fit = following.then(|| app.view().map(|v| view_key(v, rect))).flatten();
|
||||
// Before the canvas's own interaction, so the view the canvas draws is the one they moved to.
|
||||
if bars {
|
||||
scroll_bars(app, ui, rect, outer, &t);
|
||||
|
||||
@@ -87,6 +87,60 @@ fn subpixel_dock_layout_does_not_change_pointer_document_coordinates() {
|
||||
assert_ne!(observed[0], observed[2], "a real device-pixel resize must still change the mapping");
|
||||
}
|
||||
|
||||
/// A document's opening view is fitted to the canvas of the first frame that shows it, and the
|
||||
/// window's layout can still move the canvas's edges over the next frames. Two starts of the app
|
||||
/// then showed one file at zooms a hair apart, on canvases alike once the window had settled. So
|
||||
/// until the view is moved or the user presses, types or scrolls, the opening fit follows the
|
||||
/// canvas.
|
||||
#[test]
|
||||
fn the_opening_fit_follows_the_canvas_until_the_view_moves_or_the_user_acts() {
|
||||
// A frame that shows the canvas `width` points wide, which limits the fit of the 800 × 600
|
||||
// artboard, with `events`: the view after it.
|
||||
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, width: f32, events: Vec<egui::Event>) -> View {
|
||||
let raw = egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(1800.0, 900.0))),
|
||||
max_texture_side: Some(8192),
|
||||
events,
|
||||
..Default::default()
|
||||
};
|
||||
let mut out = ctx.run_ui(raw, |ui| {
|
||||
let area = egui::Rect::from_min_size(pos2(54.0, 72.0), vec2(width + SCROLL_BAR, 636.0 + SCROLL_BAR));
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(area));
|
||||
show(app, &mut child);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
*app.view().unwrap()
|
||||
}
|
||||
fn opened() -> (VectorcraftApp, egui::Context) {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 800, "height": 600, "created": 0})).unwrap();
|
||||
(app, egui::Context::default())
|
||||
}
|
||||
let first_shown_at = |width: f32| {
|
||||
let (mut app, ctx) = opened();
|
||||
frame(&mut app, &ctx, width, vec![]).zoom
|
||||
};
|
||||
let (narrow, wide) = (first_shown_at(700.0), first_shown_at(702.0));
|
||||
assert!(narrow < wide, "the canvas's width limits the fit: {narrow} and {wide}");
|
||||
let escape = || egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE };
|
||||
|
||||
// The first frame lays the canvas out 2 points narrower than the next: the document opens at
|
||||
// the zoom of the settled canvas, as it does when the first frame has it already.
|
||||
let (mut app, ctx) = opened();
|
||||
frame(&mut app, &ctx, 700.0, vec![]);
|
||||
assert_eq!(frame(&mut app, &ctx, 702.0, vec![]).zoom, wide);
|
||||
// Once the user has pressed a key, the canvas changing keeps the view, as resizing the window
|
||||
// does.
|
||||
assert_eq!(frame(&mut app, &ctx, 702.0, vec![escape()]).zoom, wide);
|
||||
assert_eq!(frame(&mut app, &ctx, 700.0, vec![]).zoom, wide);
|
||||
|
||||
// A view moved before the canvas changes stays where it was moved to.
|
||||
let (mut app, ctx) = opened();
|
||||
frame(&mut app, &ctx, 700.0, vec![]);
|
||||
app.view_mut().unwrap().zoom = 0.5;
|
||||
assert_eq!(frame(&mut app, &ctx, 702.0, vec![]).zoom, 0.5);
|
||||
}
|
||||
|
||||
/// A rendered raster becomes the image egui's own conversion makes of it, half-transparent
|
||||
/// edges and all; one whose bytes don't match its size becomes an empty image of that size.
|
||||
#[test]
|
||||
|
||||
@@ -101,7 +101,8 @@ pub(super) const SPEC: DialogSpec = DialogSpec {
|
||||
"section",
|
||||
]
|
||||
},
|
||||
min_width: 650.0,
|
||||
min_width: 720.0,
|
||||
max_width: Some(720.0),
|
||||
..DialogSpec::FORM
|
||||
};
|
||||
|
||||
@@ -885,40 +886,81 @@ pub(crate) fn opentype_body(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The key under which the dialog keeps its footer's height for the next frame: the dialog's own
|
||||
/// id, so the stored value is found on every frame.
|
||||
fn footer_id(ui: &egui::Ui) -> egui::Id {
|
||||
ui.layer_id().id.with("char-style-footer")
|
||||
}
|
||||
|
||||
/// The dialog's body: the section list beside the selected section's content, which scrolls when it
|
||||
/// is taller than the window (General lists every attribute of the style and OpenType every
|
||||
/// feature), and the footer below it - Reset Panel, the Preview checkbox and an invalid value's
|
||||
/// error. The content's height is the space the footer leaves, measured last frame, so the buttons
|
||||
/// stay on screen at any interface scale; the section list is outside the scroll area and stays put
|
||||
/// while the content scrolls. Returns whether the dialog asks to be cancelled (never: Cancel comes
|
||||
/// from the button row in `dialogs::show`).
|
||||
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
// The content scrolls inside a shorter window: General lists every attribute of the style and
|
||||
// OpenType every feature, so the window stays within the screen and its buttons stay visible.
|
||||
// The footer was measured last frame, so the room left for the content keeps it on screen at any
|
||||
// interface scale; the section list stays put while the content scrolls.
|
||||
let footer = ui.ctx().data(|m| m.get_temp::<f32>(footer_id(ui)).unwrap_or(120.0));
|
||||
let room = (ui.available_height() - footer - 8.0).clamp(0.0, 420.0);
|
||||
ui.horizontal(|ui| {
|
||||
section_list(ui, d);
|
||||
ui.separator();
|
||||
ui.vertical(|ui| {
|
||||
if d.str("section") == "basic" {
|
||||
basic_body(ui, d);
|
||||
} else if d.str("section") == "advanced" {
|
||||
advanced_body(ui, d);
|
||||
} else if d.str("section") == "color" {
|
||||
color_body(ui, d);
|
||||
} else if d.str("section") == "opentype" {
|
||||
opentype_body(ui, d);
|
||||
} else {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tl!("Style Name:"));
|
||||
form::text(ui, d, "name", 190.0);
|
||||
});
|
||||
ui.separator();
|
||||
if let Ok(attrs) = edited_attrs(d) {
|
||||
for (key, value) in attrs {
|
||||
ui.label(format!("{}: {}", super::humanize(&key), value));
|
||||
// The section list handles a click before this frame's content is laid out, so the section
|
||||
// read here is the one just picked: its own scroll offset is used from the first frame.
|
||||
let section = d.str("section");
|
||||
let content_w = ui.available_width();
|
||||
// Long sections scroll both ways: at a large interface scale the window has fewer points, and
|
||||
// a wide row (a character-format grid) must stay inside it rather than widen the window.
|
||||
egui::ScrollArea::both().id_salt(("char-style-sections", section)).max_height(room).max_width(content_w).auto_shrink([false, true]).show(
|
||||
ui,
|
||||
|ui| {
|
||||
ui.vertical(|ui| {
|
||||
if d.str("section") == "basic" {
|
||||
basic_body(ui, d);
|
||||
} else if d.str("section") == "advanced" {
|
||||
advanced_body(ui, d);
|
||||
} else if d.str("section") == "color" {
|
||||
color_body(ui, d);
|
||||
} else if d.str("section") == "opentype" {
|
||||
opentype_body(ui, d);
|
||||
} else {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tl!("Style Name:"));
|
||||
form::text(ui, d, "name", 190.0);
|
||||
});
|
||||
ui.separator();
|
||||
if let Ok(attrs) = edited_attrs(d) {
|
||||
for (key, value) in attrs {
|
||||
ui.label(format!("{}: {}", super::humanize(&key), value));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
reset_button(ui, d);
|
||||
match edited_attrs(d) {
|
||||
Ok(attrs) => {
|
||||
let params = json!({"name": d.str("__style"), "to": d.str("name"), "attrs": attrs});
|
||||
form::preview(app, ui, d, tl!("Character Style Options"), "charStyle.edit", params);
|
||||
}
|
||||
Err(why) => attrs_error(ui, &why),
|
||||
let footer_h = ui
|
||||
.scope(|ui| {
|
||||
reset_button(ui, d);
|
||||
match edited_attrs(d) {
|
||||
Ok(attrs) => {
|
||||
let params = json!({"name": d.str("__style"), "to": d.str("name"), "attrs": attrs});
|
||||
form::preview(app, ui, d, tl!("Character Style Options"), "charStyle.edit", params);
|
||||
}
|
||||
Err(why) => attrs_error(ui, &why),
|
||||
}
|
||||
})
|
||||
.response
|
||||
.rect
|
||||
.height();
|
||||
let was = ui.ctx().data(|m| m.get_temp::<f32>(footer_id(ui)));
|
||||
ui.ctx().data_mut(|m| m.insert_temp(footer_id(ui), footer_h));
|
||||
if was != Some(footer_h) {
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
false
|
||||
}
|
||||
@@ -952,6 +994,257 @@ mod tests {
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
/// The texts a frame drew, with their screen rects.
|
||||
fn texts_of(out: &egui::FullOutput) -> Vec<(String, egui::Rect)> {
|
||||
fn collect(s: &egui::Shape, out: &mut Vec<(String, egui::Rect)>) {
|
||||
match s {
|
||||
egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect())),
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| collect(s, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut texts = vec![];
|
||||
for c in &out.shapes {
|
||||
collect(&c.shape, &mut texts);
|
||||
}
|
||||
texts
|
||||
}
|
||||
|
||||
/// The texts of a few headless frames in `ctx` at a screen `w` by `h`, with their screen rects:
|
||||
/// the dialog lays its scroll area out and measures its footer over the first frames.
|
||||
fn frame_texts_in(ctx: &egui::Context, app: &mut VectorcraftApp, w: f32, h: f32) -> Vec<(String, egui::Rect)> {
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(w, h));
|
||||
let mut texts = vec![];
|
||||
for _ in 0..5 {
|
||||
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| super::super::show(app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
texts = texts_of(&out);
|
||||
}
|
||||
texts
|
||||
}
|
||||
|
||||
/// The texts of a few headless frames at a screen `w` by `h`, and the context they were drawn in.
|
||||
fn frame_texts_at(app: &mut VectorcraftApp, w: f32, h: f32) -> (egui::Context, Vec<(String, egui::Rect)>) {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let texts = frame_texts_in(&ctx, app, w, h);
|
||||
(ctx, texts)
|
||||
}
|
||||
|
||||
/// The General section lists every attribute of the style, so the sections scroll inside a
|
||||
/// window shorter than that list: the buttons stay on the screen, the window keeps its width in
|
||||
/// every section, and the content is clipped by the scroll area's viewport.
|
||||
#[test]
|
||||
fn the_sections_scroll_in_a_window_shorter_than_the_general_list() {
|
||||
let mut app = app();
|
||||
app.run(
|
||||
"charStyle.new",
|
||||
json!({"name": "Body", "attrs": {
|
||||
"font_family": "Source Sans 3", "font_style": "Regular", "size": 24.0, "leading": 30.0,
|
||||
"kerning": 0.0, "tracking": 5.0, "all_caps": false, "small_caps": null,
|
||||
"position": {"kind": "normal", "size": 100.0, "position": 0.0}, "underline": false,
|
||||
"strikethrough": false, "standardVerticalRoman": false, "charAlign": "romanBaseline",
|
||||
"h_scale": 100.0, "v_scale": 100.0, "baseline_shift": 0.0, "rotation": 0.0,
|
||||
"language": "English: USA", "overprint_fill": false, "overprint_stroke": false,
|
||||
"stroke_width": 0.0, "features": ["liga", "calt", "dlig", "swsh", "salt", "titl", "ordn", "frac"]
|
||||
}}),
|
||||
)
|
||||
.unwrap();
|
||||
open(&mut app, "Body").unwrap();
|
||||
let (ctx, texts) = frame_texts_at(&mut app, 900.0, 560.0);
|
||||
let in_screen = |label: &str| {
|
||||
let r = texts.iter().find(|(text, _)| text == label).unwrap_or_else(|| panic!("{label} is drawn")).1;
|
||||
r.top() >= 0.0 && r.bottom() <= 560.0 + 1.0
|
||||
};
|
||||
assert!(in_screen("OK") && in_screen("Cancel"), "the buttons stay on a 560-point screen");
|
||||
let dialog = egui::Id::unique(("dialog", "charStyleOptions"));
|
||||
let width = ctx.memory(|m| m.area_rect(dialog)).expect("the dialog is drawn").width();
|
||||
assert!((700.0..=820.0).contains(&width), "the dialog keeps its 720-point width: {width}");
|
||||
let reset = texts.iter().find(|(text, _)| text == "Reset Panel").expect("the footer is drawn").1.top();
|
||||
assert!(
|
||||
texts.iter().any(|(text, rect)| text.contains(':') && rect.bottom() > reset + 1.0),
|
||||
"the General list is taller than the scroll area and runs under the footer: {texts:?}"
|
||||
);
|
||||
// A short section uses the same width.
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("section".into(), json!("basic"));
|
||||
let _ = frame_texts_in(&ctx, &mut app, 900.0, 560.0);
|
||||
let basic = ctx.memory(|m| m.area_rect(dialog)).expect("the dialog is drawn").width();
|
||||
assert!((basic - width).abs() < 0.5, "the width does not change with the section: {width} -> {basic}");
|
||||
}
|
||||
/// The dialog stays usable in the smallest window the app allows (800 by 500 points): in every
|
||||
/// section the section list, OK and Cancel are on the screen.
|
||||
#[test]
|
||||
fn every_section_and_the_buttons_fit_the_smallest_window() {
|
||||
let mut app = app();
|
||||
app.run("charStyle.new", json!({"name": "Body"})).unwrap();
|
||||
open(&mut app, "Body").unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
const W: f32 = 800.0;
|
||||
const H: f32 = 500.0;
|
||||
let labels = ["General", "Basic Character Formats", "Advanced Character Formats", "Character Color", "OpenType Features", "OK", "Cancel"];
|
||||
for section in ["general", "basic", "advanced", "color", "opentype"] {
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("section".into(), json!(section));
|
||||
let texts = frame_texts_in(&ctx, &mut app, W, H);
|
||||
for label in labels {
|
||||
let r = texts.iter().find(|(text, _)| text == label).unwrap_or_else(|| panic!("{label} is drawn in {section}")).1;
|
||||
assert!(r.top() >= 0.0 && r.bottom() <= H + 1.0, "{label} stays on an 800 by 500 screen in {section}: {r:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// At 2x UI scale the smallest window (800 by 500 pixels) holds only 400 by 250 points: in
|
||||
/// every section the section list and both buttons stay on the screen.
|
||||
#[test]
|
||||
fn every_section_and_the_buttons_fit_the_smallest_window_at_twice_the_scale() {
|
||||
let mut app = app();
|
||||
app.run(
|
||||
"charStyle.new",
|
||||
json!({"name": "Body", "attrs": {
|
||||
"font_family": "Source Sans 3", "font_style": "Regular", "size": 24.0, "leading": 30.0,
|
||||
"kerning": 0.0, "tracking": 5.0, "all_caps": false, "small_caps": null,
|
||||
"position": {"kind": "normal", "size": 100.0, "position": 0.0}, "underline": false,
|
||||
"strikethrough": false, "standardVerticalRoman": false, "charAlign": "romanBaseline",
|
||||
"h_scale": 100.0, "v_scale": 100.0, "baseline_shift": 0.0, "rotation": 0.0,
|
||||
"language": "English: USA", "overprint_fill": false, "overprint_stroke": false,
|
||||
"stroke_width": 0.0, "features": ["liga", "calt", "dlig", "swsh", "salt", "titl", "ordn", "frac"]
|
||||
}}),
|
||||
)
|
||||
.unwrap();
|
||||
open(&mut app, "Body").unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
ctx.set_zoom_factor(2.0);
|
||||
const W: f32 = 400.0;
|
||||
const H: f32 = 250.0;
|
||||
let labels = ["General", "Basic Character Formats", "Advanced Character Formats", "Character Color", "OpenType Features", "OK", "Cancel"];
|
||||
for section in ["general", "basic", "advanced", "color", "opentype"] {
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("section".into(), json!(section));
|
||||
let texts = frame_texts_in(&ctx, &mut app, W, H);
|
||||
for label in labels {
|
||||
let r = texts.iter().find(|(text, _)| text == label).unwrap_or_else(|| panic!("{label} is drawn in {section}")).1;
|
||||
assert!(
|
||||
r.top() >= 0.0 && r.bottom() <= H + 1.0 && r.left() >= 0.0 && r.right() <= W + 1.0,
|
||||
"{label} stays on a 400 by 250 point screen at 2x in {section}: {r:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// A wheel over the content scrolls it; the section list and both buttons stay put.
|
||||
let at = egui::Pos2::new(300.0, 60.0);
|
||||
let mut after = texts.clone();
|
||||
for _ in 0..8 {
|
||||
let events = vec![
|
||||
egui::Event::PointerMoved(at),
|
||||
egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
delta: egui::vec2(-40.0, -40.0),
|
||||
phase: egui::TouchPhase::Move,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
},
|
||||
];
|
||||
let mut out = ctx.run_ui(
|
||||
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(W, H))), events, ..Default::default() },
|
||||
|ui| super::super::show(&mut app, ui.ctx()),
|
||||
);
|
||||
out.textures_delta.clear();
|
||||
after = texts_of(&out);
|
||||
}
|
||||
for label in labels {
|
||||
let r = after.iter().find(|(text, _)| text == label).unwrap_or_else(|| panic!("{label} is drawn in {section}")).1;
|
||||
assert!(r.top() >= 0.0 && r.bottom() <= H + 1.0, "{label} stays after scrolling in {section}: {r:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Switching from a scrolled General section to a shorter one and back keeps General's own
|
||||
/// scroll offset: the section is read after the list handles the click, so the click frame
|
||||
/// stores no offset for the section that is leaving.
|
||||
#[test]
|
||||
fn a_scrolled_section_keeps_its_offset_after_switching_away_and_back() {
|
||||
let mut app = app();
|
||||
app.run(
|
||||
"charStyle.new",
|
||||
json!({"name": "Body", "attrs": {
|
||||
"font_family": "Source Sans 3", "font_style": "Regular", "size": 24.0, "leading": 30.0,
|
||||
"kerning": 0.0, "tracking": 5.0, "all_caps": false, "small_caps": null,
|
||||
"position": {"kind": "normal", "size": 100.0, "position": 0.0}, "underline": false,
|
||||
"strikethrough": false, "standardVerticalRoman": false, "charAlign": "romanBaseline",
|
||||
"h_scale": 100.0, "v_scale": 100.0, "baseline_shift": 0.0, "rotation": 0.0,
|
||||
"language": "English: USA", "overprint_fill": false, "overprint_stroke": false,
|
||||
"stroke_width": 0.0, "features": ["liga", "calt", "dlig", "swsh", "salt", "titl", "ordn", "frac"]
|
||||
}}),
|
||||
)
|
||||
.unwrap();
|
||||
open(&mut app, "Body").unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 560.0));
|
||||
let frame = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
|
||||
let mut out =
|
||||
ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| super::super::show(app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
texts_of(&out)
|
||||
};
|
||||
let mut texts = vec![];
|
||||
for _ in 0..5 {
|
||||
texts = frame(&mut app, vec![]);
|
||||
}
|
||||
let y = |texts: &[(String, egui::Rect)], label: &str| {
|
||||
texts.iter().find(|(text, _)| text == label).unwrap_or_else(|| panic!("{label} is drawn")).1.top()
|
||||
};
|
||||
eprintln!("DEBUG initial texts: {texts:?}");
|
||||
let general_top = y(&texts, "Style Name:");
|
||||
let before = texts.clone();
|
||||
// Scroll General down with the wheel.
|
||||
let at = egui::Pos2::new(600.0, 200.0);
|
||||
for _ in 0..8 {
|
||||
let events = vec![
|
||||
egui::Event::PointerMoved(at),
|
||||
egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
delta: egui::vec2(0.0, -40.0),
|
||||
phase: egui::TouchPhase::Move,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
},
|
||||
];
|
||||
texts = frame(&mut app, events);
|
||||
}
|
||||
// Let the smooth wheel scroll finish before changing sections.
|
||||
for _ in 0..15 {
|
||||
texts = frame(&mut app, vec![]);
|
||||
}
|
||||
let scrolled = texts.clone();
|
||||
assert!(
|
||||
before.iter().any(|(label, _)| !scrolled.iter().any(|(other, _)| other == label))
|
||||
&& scrolled.iter().any(|(label, _)| !before.iter().any(|(other, _)| other == label)),
|
||||
"General scrolls to other attributes"
|
||||
);
|
||||
// Click Basic Character Formats, then General.
|
||||
let click = |app: &mut VectorcraftApp, label: &str| {
|
||||
let texts = frame(app, vec![]);
|
||||
let pos = texts.iter().find(|(text, _)| text == label).unwrap_or_else(|| panic!("{label} is drawn")).1.center();
|
||||
let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
for e in [egui::Event::PointerMoved(pos), button(true), button(false)] {
|
||||
let _ = frame(app, vec![e]);
|
||||
}
|
||||
let _ = frame(app, vec![]);
|
||||
};
|
||||
click(&mut app, "Basic Character Formats");
|
||||
let basic = frame(&mut app, vec![]);
|
||||
assert!(y(&basic, "Font Family:") < general_top + 40.0, "a short section opens at its top");
|
||||
click(&mut app, "General");
|
||||
let back = frame(&mut app, vec![]);
|
||||
let mut same = 0;
|
||||
for (label, old) in &scrolled {
|
||||
if let Some((_, new)) = back.iter().find(|(text, _)| text == label) {
|
||||
assert!((new.top() - old.top()).abs() < 1.0, "General keeps its offset at {label}: {old:?} -> {new:?}");
|
||||
same += 1;
|
||||
}
|
||||
}
|
||||
assert!(same > 0, "General draws its content again: {back:?}");
|
||||
}
|
||||
#[test]
|
||||
fn unchanged_basic_fields_preserve_original_keys() {
|
||||
for attrs in [
|
||||
|
||||
@@ -114,17 +114,19 @@ pub(super) fn show_preview(ui: &mut egui::Ui, tex: Option<&egui::TextureHandle>,
|
||||
|
||||
/// Open `bytes` (or place them with the `file.place` params `place`) through the dialog when the
|
||||
/// file is a PDF (or `.ai`/`.ait`) with several pages or a password; false when there's nothing to
|
||||
/// ask (the caller goes on), including a file that carries its VectorCraft document when opening.
|
||||
/// ask (the caller goes on), including, when opening, a file that carries its VectorCraft document
|
||||
/// and a `.ai`/`.ait` with its editing data.
|
||||
pub fn offer(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option<String>, place: Option<&Value>) -> bool {
|
||||
if !fileio::detect(name, bytes).is_some_and(|f| matches!(f.id, "pdf" | "ai" | "ait")) {
|
||||
let Some(format) = fileio::detect(name, bytes).map(|f| f.id).filter(|id| matches!(*id, "pdf" | "ai" | "ait")) else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
// A file carrying its VectorCraft document (a saved .ai, or a PDF saved with Preserve Editing)
|
||||
// opens as that document, every artboard and layer: there are no pages to pick. Placing still
|
||||
// asks, since a place takes one page.
|
||||
// opens as that document, every artboard and layer: there are no pages to pick. So does a .ai
|
||||
// or .ait with its editing data, which holds them all (#6135); a PDF with editing data still
|
||||
// asks, as its pages are what it is. Placing still asks, since a place takes one page.
|
||||
// The file is read once for both checks.
|
||||
let opened = vectorcraft_pdf::open_pdf(bytes, None);
|
||||
if place.is_none() && opened.as_ref().is_ok_and(|o| o.editing().is_some()) {
|
||||
if place.is_none() && opened.as_ref().is_ok_and(|o| o.editing().is_some() || (format != "pdf" && o.has_illustrator_data())) {
|
||||
return false;
|
||||
}
|
||||
let (pages, locked) = match opened.map(|o| o.page_count()) {
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::NodeKind;
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_testkit::pdf::{PdfPage, pdf};
|
||||
use vectorcraft_testkit::ai;
|
||||
use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf, pdf_with};
|
||||
|
||||
use super::*;
|
||||
use crate::io;
|
||||
@@ -197,3 +198,72 @@ fn placing_a_saved_ai_still_asks_for_the_page() {
|
||||
assert!(import_pdf::offer(&mut app, "two.ai", &bytes, None, Some(&json!({}))));
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().str("mode"), "place");
|
||||
}
|
||||
|
||||
/// Two pages, 100 and 200 pt wide; with `editing_data` the first page has the keys of an editor's
|
||||
/// copy of the art.
|
||||
fn two_pages(editing_data: bool, password: Option<&str>) -> Vec<u8> {
|
||||
let mut first = PdfPage::new(100.0, 100.0, "0 g 0 0 10 10 re f");
|
||||
if editing_data {
|
||||
first.entries = "/PieceInfo << /Illustrator << /Private << /AIPrivateData1 7 /NumBlock 1 >> >> >>".into();
|
||||
}
|
||||
pdf(&[first, PdfPage::new(200.0, 100.0, "1 0 0 rg 0 0 30 30 re f")], password)
|
||||
}
|
||||
|
||||
/// Two 100 pt pages and, on the first, editing data with two artboards to match and a layer of its
|
||||
/// own, which draws the square the first page draws.
|
||||
fn two_pages_with_a_layer_in_the_editing_data(layer: &str) -> Vec<u8> {
|
||||
let point = |x: i32, y: i32, n: u8| format!("%_{x} {y} /RealPointRelToROrigin\n%_ (PositionPoint{n}) ,\n");
|
||||
let board = |top: i32| format!("%_/Dictionary :\n{}{}%_; ,\n", point(0, top, 1), point(100, top - 100, 2));
|
||||
let boards = format!("%_/Document :\n%_/Dictionary :\n%_/Array :\n{}{}%_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n", board(100), board(-50));
|
||||
let art = format!("0 0 0 1 k\n{}f\n", ai::rect(10.0, 10.0, 30.0, 30.0));
|
||||
let data = ai::editing_data(100.0, 100.0, &format!("{boards}{}", ai::layer(layer, &art)));
|
||||
let mut first = PdfPage::new(100.0, 100.0, "0 0 0 1 k 10 10 20 20 re f");
|
||||
first.entries = format!("/PieceInfo << /Illustrator << /Private << /AIPrivateData1 {} 0 R /NumBlock 1 >> >> >>", first_extra(2));
|
||||
let stream = format!("<< /Length {} >>\nstream\n{data}\nendstream", data.len());
|
||||
pdf_with(&[first, PdfPage::new(100.0, 100.0, "")], &[&stream], None)
|
||||
}
|
||||
|
||||
/// #6135: the editing data of a `.ai` holds every artboard, so opening one asks nothing, opens
|
||||
/// them all and reads the art from that data: the layer is in nothing else.
|
||||
#[test]
|
||||
fn opening_an_ai_with_editing_data_reads_it_without_asking() {
|
||||
let mut app = app();
|
||||
let bytes = two_pages_with_a_layer_in_the_editing_data("Only in the editing data");
|
||||
io::open_bytes(&mut app, "two.ai", &bytes, Some("/tmp/two.ai".into())).unwrap();
|
||||
assert!(app.ui.dialog.is_none(), "no Import PDF dialog");
|
||||
assert_eq!(app.session.documents().len(), 2);
|
||||
assert_eq!(artboard_widths(&app), [100.0, 100.0]);
|
||||
let layers = &app.session.active().unwrap().doc.layers;
|
||||
assert!(layers.iter().any(|l| l.name.as_deref() == Some("Only in the editing data")), "read from the editing data");
|
||||
assert!(!import_pdf::offer(&mut app, "two.ait", &bytes, None, None), "a template too");
|
||||
assert!(app.ui.dialog.is_none());
|
||||
}
|
||||
|
||||
/// A `.ai` whose editing data can't be read asks nothing either, and every page opens from the PDF
|
||||
/// part. Asking at that point is #6134.
|
||||
#[test]
|
||||
fn opening_an_ai_with_unreadable_editing_data_opens_every_page_without_asking() {
|
||||
let mut app = app();
|
||||
io::open_bytes(&mut app, "two.ai", &two_pages(true, None), Some("/tmp/two.ai".into())).unwrap();
|
||||
assert!(app.ui.dialog.is_none(), "no Import PDF dialog");
|
||||
assert_eq!(artboard_widths(&app), [100.0, 200.0]);
|
||||
}
|
||||
|
||||
/// #6135: the dialog stays for what still has something to ask: a PDF, even one with editing data
|
||||
/// keys, a `.ai` that is only a PDF, a password, and Place.
|
||||
#[test]
|
||||
fn the_import_dialog_stays_where_there_is_something_to_ask() {
|
||||
for (name, bytes) in [("two.pdf", two_pages(true, None)), ("two.ai", two_pages(false, None))] {
|
||||
let mut app = app();
|
||||
assert!(import_pdf::offer(&mut app, name, &bytes, None, None), "{name}");
|
||||
assert_eq!((app.ui.dialog.as_ref().unwrap().str("mode"), field(&app, "pageCount")), ("open".into(), json!(2)), "{name}");
|
||||
}
|
||||
|
||||
let mut app = app();
|
||||
assert!(import_pdf::offer(&mut app, "locked.ai", &two_pages(true, Some("pw")), None, None));
|
||||
assert_eq!(field(&app, "__locked"), true);
|
||||
|
||||
let mut app = self::app();
|
||||
assert!(import_pdf::offer(&mut app, "two.ai", &two_pages(true, None), None, Some(&json!({}))));
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().str("mode"), "place");
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Type → Find Font…: the fonts the document uses (missing ones marked), Find (select the text
|
||||
//! using a font), Change / Change All to another font from the list Replace With Font From shows
|
||||
//! (the document's fonts, the recent fonts or every installed font), and Find in Folder… (the
|
||||
//! missing fonts' files, in the Missing Fonts dialog: `ui.findFontsInFolder`).
|
||||
//! missing fonts' files, in the Missing Fonts dialog: `ui.findFontsInFolder`). Find In limits Find
|
||||
//! to the active artboard or the selected layers.
|
||||
|
||||
use egui::Ui;
|
||||
use serde_json::{Value, json};
|
||||
@@ -23,9 +24,10 @@ pub fn open_on_missing(app: &mut VectorcraftApp) {
|
||||
open_at(app, first.unwrap_or(0));
|
||||
}
|
||||
|
||||
/// Open the dialog with the document's font `selected` (an index into `text.fonts`) chosen, and
|
||||
/// System's font ([`system_family`]) to change to.
|
||||
/// Open the dialog with the document's font `selected` (an index into `text.fonts`) chosen,
|
||||
/// System's font ([`system_family`]) to change to, and the last Find In choice (Document at first).
|
||||
fn open_at(app: &mut VectorcraftApp, selected: usize) {
|
||||
let find_in = FIND_IN.iter().find(|f| **f == app.ui.find_font_in).copied().unwrap_or("document");
|
||||
let used = fonts(app);
|
||||
let source = "system";
|
||||
let rows = replacement_rows(app, source, &used);
|
||||
@@ -33,7 +35,7 @@ fn open_at(app: &mut VectorcraftApp, selected: usize) {
|
||||
let style = rows.iter().find(|r| r.family.eq_ignore_ascii_case(&family)).map(|r| r.style.clone()).unwrap_or_default();
|
||||
app.ui.dialog = Some(Dialog::new(
|
||||
"findFont",
|
||||
json!({ "selected": selected, "family": family, "style": style, "selectionOnly": false, "replaceFrom": source, "rows": rows_json(&rows) }),
|
||||
json!({ "selected": selected, "family": family, "style": style, "selectionOnly": false, "replaceFrom": source, "rows": rows_json(&rows), "findIn": find_in }),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -210,6 +212,42 @@ fn set_source(app: &VectorcraftApp, d: &mut Dialog, source: &str, used: &[Value]
|
||||
d.fields.insert("style".into(), json!(pick.map(|r| r.style.clone()).unwrap_or_default()));
|
||||
}
|
||||
|
||||
/// Find In's choices (the `findIn` field): the whole document, the active artboard, or the layers
|
||||
/// highlighted in the Layers panel (else the current layer).
|
||||
const FIND_IN: [&str; 3] = ["document", "artboard", "layers"];
|
||||
const FIND_IN_LABELS: [&str; 3] = ["Document", "Artboard", "Layers"];
|
||||
|
||||
/// The `scope` Find passes `select.font` for Find In choice `find_in`. A document without
|
||||
/// artboards, or without a layer to look in, is looked through whole.
|
||||
fn find_scope(app: &VectorcraftApp, find_in: &str) -> Value {
|
||||
let Some(st) = app.session.active() else { return json!("document") };
|
||||
let n = st.doc.artboards.len();
|
||||
match find_in {
|
||||
"artboard" if n > 0 => json!({ "artboards": [crate::panels::artboards::selected(app, n)] }),
|
||||
"layers" => {
|
||||
let is_layer = |id: &vectorcraft_doc::NodeId| st.doc.node(*id).is_some_and(vectorcraft_doc::Node::is_layer);
|
||||
let mut layers: Vec<u64> = st.highlighted_rows().into_iter().filter(is_layer).map(|id| id.0).collect();
|
||||
if layers.is_empty() {
|
||||
layers.extend(st.current_layer().map(|id| id.0));
|
||||
}
|
||||
if layers.is_empty() { json!("document") } else { json!({ "layers": layers }) }
|
||||
}
|
||||
_ => json!("document"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the dialog's button `button` (find, change or changeAll) on the document font `from`.
|
||||
fn run_button(app: &mut VectorcraftApp, d: &Dialog, button: &str, from: &Value) -> Result<Value, String> {
|
||||
match button {
|
||||
"find" => {
|
||||
let scope = find_scope(app, &d.str("findIn"));
|
||||
app.run("select.font", json!({ "family": from["family"], "style": from["style"], "scope": scope }))
|
||||
}
|
||||
"change" => change(app, d, from, false),
|
||||
_ => change(app, d, from, true),
|
||||
}
|
||||
}
|
||||
|
||||
fn fonts(app: &mut VectorcraftApp) -> Vec<Value> {
|
||||
app.session.execute("text.fonts", &json!({})).ok().and_then(|v| v.as_array().cloned()).unwrap_or_default()
|
||||
}
|
||||
@@ -342,6 +380,14 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
ui.add_space(14.0);
|
||||
ui.horizontal(|ui| {
|
||||
widgets::field_label(ui, tl!("Find In"));
|
||||
let at = FIND_IN.iter().position(|s| *s == d.str("findIn")).unwrap_or(0);
|
||||
if let Some(i) = widgets::dropdown_named(ui, "ff-find-in", Some(tl!("Find In")), FIND_IN_LABELS[at], &FIND_IN_LABELS, 120.0) {
|
||||
d.fields.insert("findIn".into(), json!(FIND_IN[i]));
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
// An empty list (Recent before any font was applied) leaves nothing to change to.
|
||||
let replace = sel < list.len() && can_replace(&d, families.as_deref());
|
||||
ui.horizontal(|ui| {
|
||||
@@ -362,14 +408,10 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
});
|
||||
});
|
||||
});
|
||||
app.ui.find_font_in = d.str("findIn");
|
||||
let sel = d.fields.get("selected").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
if let (Some(a), Some(from)) = (act, list.get(sel)) {
|
||||
let r = match a {
|
||||
"find" => app.run("select.font", json!({ "family": from["family"], "style": from["style"] })),
|
||||
"change" => change(app, &d, from, false),
|
||||
_ => change(app, &d, from, true),
|
||||
};
|
||||
match r {
|
||||
match run_button(app, &d, a, from) {
|
||||
// The rows are a snapshot: the document's fonts and the recent fonts have moved on.
|
||||
Ok(_) if a != "find" => {
|
||||
let used = fonts(app);
|
||||
@@ -433,6 +475,48 @@ mod tests {
|
||||
assert_eq!(fonts(&mut app)[0]["family"], "Source Sans 3");
|
||||
}
|
||||
|
||||
/// Find In passes Find the active artboard, or the selected layers (else the current
|
||||
/// layer), as `select.font`'s scope.
|
||||
#[test]
|
||||
fn find_in_limits_find_to_the_artboard_or_the_layers() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
app.session.execute("artboard.new", &json!({"x": 200, "y": 0, "width": 100, "height": 100})).unwrap();
|
||||
let text = |app: &mut VectorcraftApp, x: i32| {
|
||||
app.session.execute("text.create", &json!({"x": x, "y": 40, "text": "Hi", "font": "Inter"})).unwrap()["id"].as_u64().unwrap()
|
||||
};
|
||||
let first = text(&mut app, 10);
|
||||
let layer = app.session.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
let second = text(&mut app, 210);
|
||||
let third = text(&mut app, 20);
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
// Choose Find In on the open dialog, draw a frame, then press Find.
|
||||
let find = |app: &mut VectorcraftApp, find_in: &str| {
|
||||
open(app);
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("findIn".into(), json!(find_in));
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
let d = app.ui.dialog.clone().unwrap();
|
||||
let from = fonts(app).into_iter().find(|f| f["family"] == "Inter").unwrap();
|
||||
run_button(app, &d, "find", &from).unwrap();
|
||||
let mut ids: Vec<u64> = app.session.active().unwrap().selection.objects.iter().map(|id| id.0).collect();
|
||||
ids.sort_unstable();
|
||||
ids
|
||||
};
|
||||
open(&mut app);
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().str("findIn"), "document");
|
||||
assert_eq!(find(&mut app, "document"), [first, second, third]);
|
||||
// Without a view, the active artboard is the first.
|
||||
assert_eq!(find(&mut app, "artboard"), [first, third]);
|
||||
assert_eq!(find_scope(&app, "layers"), json!({ "layers": [layer] }));
|
||||
assert_eq!(find(&mut app, "layers"), [second, third]);
|
||||
// Closing and opening the dialog again keeps the last choice.
|
||||
app.ui.dialog = None;
|
||||
open(&mut app);
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().str("findIn"), "layers");
|
||||
}
|
||||
|
||||
/// Change All leaves locked text in its font, and the status bar says so.
|
||||
#[test]
|
||||
fn change_all_clears_the_skip_warning_after_unlocking_text() {
|
||||
|
||||
@@ -2699,6 +2699,7 @@ digits Malayalam Малаялам
|
||||
Reset Transformation Нулиране на трансформацията
|
||||
Replace With Font Замяна с шрифт
|
||||
Replace With Font From Замяна с шрифт от
|
||||
Find In Търсене в
|
||||
System Система
|
||||
Replaces the saved preset of that name. Заменя записаната предварителна настройка с това име.
|
||||
Replace… Замяна…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4000,6 +4000,7 @@ effect Regular Obično
|
||||
Find in Folder… Pronađi u folderu…
|
||||
Replace With Font Zamijeni fontom
|
||||
Replace With Font From Zamijeni fontom iz
|
||||
Find In Traži u
|
||||
System Sistem
|
||||
— substituted by {style} — zamijenjeno stilom {style}
|
||||
— {n} characters from another font — {n} znakova iz drugog fonta
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3406,6 +3406,7 @@ digits Malayalam Malaiàlam
|
||||
Reset Transformation Restableix la transformació
|
||||
Replace With Font Reemplaça per un tipus de lletra
|
||||
Replace With Font From Reemplaça per un tipus de lletra de
|
||||
Find In Cerca a
|
||||
System Sistema
|
||||
Replace “{name}”, the file this document was opened from? Voleu reemplaçar “{name}”, el fitxer amb què es va obrir aquest document?
|
||||
Replaces the saved preset of that name. Reemplaça el preajust desat amb aquest nom.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4050,6 +4050,7 @@ effect Regular Pravidelný
|
||||
Find in Folder… Najít ve složce…
|
||||
Replace With Font Nahradit písmem
|
||||
Replace With Font From Nahradit písmem z
|
||||
Find In Hledat v
|
||||
System Systém
|
||||
— substituted by {style} — nahrazeno řezem {style}
|
||||
— {n} characters from another font — znaky z jiného písma: {n}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3766,6 +3766,7 @@ effect Regular Regular
|
||||
Find in Folder… Find i mappe…
|
||||
Replace With Font Erstat med skrifttype
|
||||
Replace With Font From Erstat med skrifttype fra
|
||||
Find In Søg i
|
||||
System System
|
||||
— substituted by {style} — erstattet af {style}
|
||||
— {n} characters from another font — {n} tegn fra en anden skrifttype
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2731,6 +2731,7 @@ digits Malayalam Malayalam
|
||||
Reset Transformation Transformation zurücksetzen
|
||||
Replace With Font Durch Schrift ersetzen
|
||||
Replace With Font From Ersetzende Schrift aus
|
||||
Find In Suchen in
|
||||
System System
|
||||
Replaces the saved preset of that name. Ersetzt die gespeicherte Vorgabe dieses Namens.
|
||||
Replace… Ersetzen…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3341,6 +3341,7 @@ digits Malayalam Μαλαγιάλαμ
|
||||
Reset Transformation Επαναφορά μετασχηματισμού
|
||||
Replace With Font Αντικατάσταση με γραμματοσειρά
|
||||
Replace With Font From Αντικατάσταση με γραμματοσειρά από
|
||||
Find In Αναζήτηση σε
|
||||
System Σύστημα
|
||||
Replaces the saved preset of that name. Αντικαθιστά την αποθηκευμένη προεπιλογή με αυτό το όνομα.
|
||||
Require a password to open the document Απαίτηση κωδικού για το άνοιγμα εγγράφου
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 168 and column 5.
|
@@ -2774,6 +2774,7 @@ digits Malayalam Malayálam
|
||||
Reset Transformation Restablecer transformación
|
||||
Replace With Font Reemplazar con fuente
|
||||
Replace With Font From Reemplazar con fuente de
|
||||
Find In Buscar en
|
||||
System Sistema
|
||||
Replaces the saved preset of that name. Reemplaza el preajuste guardado con ese nombre.
|
||||
Replace… Reemplazar…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3969,6 +3969,7 @@ effect Regular Normaali
|
||||
Find in Folder… Etsi kansiosta…
|
||||
Replace With Font Korvaa fontilla
|
||||
Replace With Font From Korvaavan fontin lähde
|
||||
Find In Etsi kohteesta
|
||||
System Järjestelmä
|
||||
— substituted by {style} — korvattu tyylillä {style}
|
||||
— {n} characters from another font — {n} merkkiä toisesta fontista
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2766,6 +2766,7 @@ digits Malayalam Malayalam
|
||||
Reset Transformation Réinitialiser la transformation
|
||||
Replace With Font Remplacer par la police
|
||||
Replace With Font From Remplacer par une police de
|
||||
Find In Rechercher dans
|
||||
System Système
|
||||
Replaces the saved preset of that name. Remplace le paramètre prédéfini enregistré sous ce nom.
|
||||
Replace… Remplacer…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3306,6 +3306,7 @@ mark-style Japanese Japán
|
||||
Reset Transformation Átalakítás visszaállítása
|
||||
Replace With Font Helyettesítő betűtípus
|
||||
Replace With Font From Helyettesítő betűtípus forrása
|
||||
Find In Keresés helye
|
||||
System Rendszer
|
||||
Replace “{name}”, the file this document was opened from? Felülírod ezt a fájlt, amelyből a dokumentumot megnyitottad: „{name}”?
|
||||
Replaces the saved preset of that name. Felülírja az azonos nevű mentett előbeállítást.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3766,6 +3766,7 @@ effect Regular Reguler
|
||||
Find in Folder… Temukan di folder…
|
||||
Replace With Font Ganti dengan font
|
||||
Replace With Font From Ganti dengan font dari
|
||||
Find In Cari di
|
||||
System Sistem
|
||||
— substituted by {style} — digantikan oleh {style}
|
||||
— {n} characters from another font — {n} karakter dari font lain
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2270 and column 5.
|
@@ -2807,6 +2807,7 @@ digits Malayalam Malayalam
|
||||
Reset Transformation Reimposta trasformazione
|
||||
Replace With Font Sostituisci con font
|
||||
Replace With Font From Sostituisci con font da
|
||||
Find In Cerca in
|
||||
System Sistema
|
||||
Replaces the saved preset of that name. Sostituisce il preset salvato con quel nome.
|
||||
Replace… Sostituisci…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2793,6 +2793,7 @@ mark-style Japanese 日本式
|
||||
Reset Transformation 変形をリセット
|
||||
Replace With Font 置換するフォント
|
||||
Replace With Font From 置換するフォントの一覧
|
||||
Find In 検索範囲
|
||||
System システム
|
||||
Replace it with another file, or ignore it: its images keep showing the preview saved with the document. 別のファイルに置き換えるか、無視してください。無視した場合、画像にはドキュメントに保存されたプレビューが表示され続けます。
|
||||
Replaces the saved preset of that name. 同じ名前の保存済みプリセットを置き換えます。
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1330,6 +1330,7 @@ lighting Light სინათლე
|
||||
Arabic არაბული
|
||||
Hindi ჰინდი
|
||||
Farsi სპარსული
|
||||
digits Hindi ჰინდი
|
||||
digits Devanagari დევანაგარი
|
||||
digits Bengali ბენგალური
|
||||
digits Gurmukhi გურმუხი
|
||||
@@ -3559,6 +3560,7 @@ effect Regular ჩვეულებრივი
|
||||
Find in Folder… საქაღალდეში ძებნა…
|
||||
Replace With Font ჩანაცვლება შრიფტით
|
||||
Replace With Font From ჩანაცვლების შრიფტის წყარო
|
||||
Find In ძებნის არე
|
||||
System სისტემა
|
||||
— substituted by {style} — ჩანაცვლებულია: {style}
|
||||
— {n} characters from another font — სხვა შრიფტის სიმბოლოები: {n}
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -2782,6 +2782,7 @@ mark-style Japanese 일본식
|
||||
Reset Transformation 변형 재설정
|
||||
Replace With Font 바꿀 글꼴
|
||||
Replace With Font From 바꿀 글꼴 목록
|
||||
Find In 찾을 위치
|
||||
System 시스템
|
||||
Replace it with another file, or ignore it: its images keep showing the preview saved with the document. 다른 파일로 바꾸거나 무시하세요. 무시하면 해당 이미지는 문서에 저장된 미리 보기로 계속 표시됩니다.
|
||||
Replaces the saved preset of that name. 같은 이름으로 저장된 사전 설정을 대체합니다.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3780,6 +3780,7 @@ effect Regular Regelmatig
|
||||
Find in Folder… Zoeken in map…
|
||||
Replace With Font Vervangen door lettertype
|
||||
Replace With Font From Vervangen door lettertype uit
|
||||
Find In Zoeken in
|
||||
System Systeem
|
||||
— substituted by {style} — vervangen door {style}
|
||||
— {n} characters from another font — {n} tekens van een ander lettertype
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3889,6 +3889,7 @@ effect Regular Normalny
|
||||
Find in Folder… Znajdź w folderze…
|
||||
Replace With Font Zastąp czcionką
|
||||
Replace With Font From Zastąp czcionką z
|
||||
Find In Szukaj w
|
||||
System System
|
||||
— substituted by {style} — zastąpiona przez {style}
|
||||
— {n} characters from another font — znaki z innej czcionki: {n}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2314,6 +2314,7 @@ digits Malayalam Malaiala
|
||||
Reset Transformation Redefinir Transformação
|
||||
Replace With Font Substituir Com Fonte
|
||||
Replace With Font From Substituir Com Fonte De
|
||||
Find In Localizar em
|
||||
System Sistema
|
||||
Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Substitua por outro arquivo, ou ignore-o: suas imagens continuam mostrando a pré-visualização salva com o documento.
|
||||
Replaces the saved preset of that name. Substitui o predefinido salvo com esse nome.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2802,6 +2802,7 @@ digits Malayalam Малаялам
|
||||
Reset Transformation Сбросить трансформацию
|
||||
Replace With Font Заменить шрифтом
|
||||
Replace With Font From Заменить шрифтом из
|
||||
Find In Искать в
|
||||
System Система
|
||||
Replaces the saved preset of that name. Заменяет сохранённый набор с таким именем.
|
||||
Replace… Заменить…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2710,6 +2710,7 @@ digits Malayalam Malayalam
|
||||
Reset Transformation Återställ transformering
|
||||
Replace With Font Ersätt med typsnitt
|
||||
Replace With Font From Ersätt med typsnitt från
|
||||
Find In Sök i
|
||||
System System
|
||||
Replaces the saved preset of that name. Ersätter den sparade förinställningen med samma namn.
|
||||
Replace… Ersätt…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -2579,6 +2579,7 @@ digits Malayalam Malayalam
|
||||
Reset Transformation Dönüşümü Sıfırla
|
||||
Replace With Font Yazı Tipiyle Değiştir
|
||||
Replace With Font From Değiştirilecek Yazı Tipi Kaynağı
|
||||
Find In Arama yeri
|
||||
System Sistem
|
||||
Replaces the saved preset of that name. Bu adın kayıtlı ön ayarını değiştirir.
|
||||
Replace… Değiştir…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -2799,6 +2799,7 @@ digits Malayalam Малаялам
|
||||
Reset Transformation Скинути трансформацію
|
||||
Replace With Font Замінити шрифтом
|
||||
Replace With Font From Замінити шрифтом з
|
||||
Find In Шукати в
|
||||
System Система
|
||||
Replaces the saved preset of that name. Замінює збережений набір із таким ім’ям.
|
||||
Replace… Замінити…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 182 and column 5.
|
@@ -3074,6 +3074,7 @@ mark-style Japanese Kiểu Nhật
|
||||
Reset Transformation Đặt lại biến đổi
|
||||
Replace With Font Thay thế bằng phông chữ
|
||||
Replace With Font From Thay thế bằng phông chữ từ
|
||||
Find In Tìm trong
|
||||
System Hệ thống
|
||||
Replace “{name}”, the file this document was opened from? Thay thế “{name}”, tệp mà tài liệu này được mở từ đó?
|
||||
Replaces the saved preset of that name. Thay thế thiết lập sẵn đã lưu có tên đó.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 204 and column 5.
|
@@ -2677,6 +2677,7 @@ digits Malayalam 马拉雅拉姆数字
|
||||
Reset Transformation 重置变换
|
||||
Replace With Font 替换为字体
|
||||
Replace With Font From 替换字体来源
|
||||
Find In 查找范围
|
||||
System 系统
|
||||
Replaces the saved preset of that name. 替换同名已存预设。
|
||||
Replace… 替换…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3680,6 +3680,7 @@ digits Malayalam 馬來亞拉姆數字
|
||||
Reset Transformation 重設變形
|
||||
Replace With Font 取代為字體
|
||||
Replace With Font From 取代字體來源
|
||||
Find In 尋找範圍
|
||||
System 系統
|
||||
Replaces the saved preset of that name. 取代同名的已儲存預設集。
|
||||
Replace… 取代…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -106,13 +106,14 @@ fn app(fill: Value) -> VectorcraftApp {
|
||||
struct Panel {
|
||||
ctx: egui::Context,
|
||||
time: f64,
|
||||
scope: egui::Id,
|
||||
}
|
||||
|
||||
impl Panel {
|
||||
fn new() -> Self {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
Self { ctx, time: 0.0 }
|
||||
Self { ctx, time: 0.0, scope: egui::Id::NULL }
|
||||
}
|
||||
|
||||
/// One frame with `events` (and `modifiers` held); returns the texts drawn and where.
|
||||
@@ -144,23 +145,27 @@ impl Panel {
|
||||
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(260.0, 600.0))),
|
||||
..Default::default()
|
||||
};
|
||||
let mut out = self.ctx.run_ui(input, |ui| draw(app, ui));
|
||||
let mut out = self.ctx.run_ui(input, |ui| {
|
||||
self.scope = ui.scope_id();
|
||||
draw(app, ui);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
let mut v = vec![];
|
||||
out.shapes.iter().for_each(|c| texts(&c.shape, &mut v));
|
||||
v
|
||||
}
|
||||
|
||||
/// The rect of the widget keyed `key` (last frame). egui 0.37's `WidgetRect.parent_id` is
|
||||
/// the parent Ui's unstable `unique_id`, not the stable `scope_id` widgets are salted with;
|
||||
/// debug builds remember the `with(key)` that made each id, so match that.
|
||||
fn widget(&self, key: impl std::hash::Hash + std::fmt::Debug + Copy) -> Rect {
|
||||
let suffix = format!(".with({key:?})");
|
||||
self.ctx
|
||||
.viewport(|vp| {
|
||||
vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).find(|w| format!("{:?}", w.id).ends_with(&suffix)).map(|w| w.rect)
|
||||
})
|
||||
.expect("widget drawn")
|
||||
/// Match actual IDs in the panel or its immediate horizontal rows. `parent_id` is an
|
||||
/// unstable auto ID, and the human-readable `Id` debug source is absent in release builds.
|
||||
fn widget(&self, key: impl egui::AsIdSalt + Copy) -> Rect {
|
||||
// Match Ui::new_child: its default child salt is an IdSalt, not the raw string.
|
||||
let row_scope = self.scope.with(egui::IdSalt::new("child"));
|
||||
let ids = [self.scope.with(key), row_scope.with(key)];
|
||||
// Outside run_ui, read_response can prefer the older this_pass geometry after
|
||||
// end_pass swaps the passes. Use the completed frame, including after Hide Options.
|
||||
let matches: Vec<_> = self.ctx.viewport(|vp| ids.into_iter().filter_map(|id| vp.prev_pass.widgets.get(id).map(|w| w.rect)).collect());
|
||||
assert_eq!(matches.len(), 1, "expected one widget for {key:?}");
|
||||
matches[0]
|
||||
}
|
||||
|
||||
fn click(&mut self, app: &mut VectorcraftApp, at: Pos2) -> Vec<(String, Pos2)> {
|
||||
@@ -252,7 +257,7 @@ fn the_proxy_toggles_fill_and_stroke_and_stays_with_options_hidden() {
|
||||
let fill = p.widget("fill-proxy");
|
||||
p.click(&mut app, fill.left_top() + vec2(2.0, 2.0));
|
||||
assert!(app.session.fill_active, "the proxy works with the options hidden");
|
||||
p.widget(("grad-stop", 1));
|
||||
p.widget(("grad-stop", 1_usize));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -331,7 +336,7 @@ fn a_selected_diamond_shows_and_sets_its_midpoint_in_location() {
|
||||
let mut p = Panel::new();
|
||||
let texts = p.frame(&mut app, vec![], Modifiers::NONE);
|
||||
assert!(texts.iter().any(|(t, _)| t == "100%"), "the stop's location: {texts:?}");
|
||||
let diamond = p.widget(("grad-mid", 0));
|
||||
let diamond = p.widget(("grad-mid", 0_usize));
|
||||
let texts = p.click(&mut app, diamond.center());
|
||||
assert_eq!(app.session.selected_stop(), None, "the diamond replaces the stop selection");
|
||||
assert!(texts.iter().any(|(t, _)| t == "30%"), "the midpoint: {texts:?}");
|
||||
@@ -343,7 +348,7 @@ fn a_selected_diamond_shows_and_sets_its_midpoint_in_location() {
|
||||
p.frame(&mut app, vec![key(egui::Key::Enter, Modifiers::NONE)], Modifiers::NONE);
|
||||
assert!((fill(&app).gradient.stops[0].midpoint - 0.6).abs() < 1e-6, "{:?}", fill(&app).gradient.stops);
|
||||
// Clicking a stop selects it again.
|
||||
let stop = p.widget(("grad-stop", 0));
|
||||
let stop = p.widget(("grad-stop", 0_usize));
|
||||
let texts = p.click(&mut app, stop.center());
|
||||
assert_eq!(app.session.selected_stop(), Some(0));
|
||||
assert!(texts.iter().any(|(t, _)| t == "0%"));
|
||||
@@ -474,7 +479,7 @@ fn alt_drag_copies_a_stop_and_alt_dropping_on_a_stop_swaps_colours() {
|
||||
let mut app = app(json!({"gradient": {}}));
|
||||
let mut p = Panel::new();
|
||||
p.frame(&mut app, vec![], Modifiers::NONE);
|
||||
let (a, b) = (p.widget(("grad-stop", 0)).center(), p.widget(("grad-stop", 1)).center());
|
||||
let (a, b) = (p.widget(("grad-stop", 0_usize)).center(), p.widget(("grad-stop", 1_usize)).center());
|
||||
let mid = pos2((a.x + b.x) / 2.0, a.y);
|
||||
let offsets = |app: &VectorcraftApp| fill(app).gradient.stops.iter().map(|s| (s.offset * 10.0).round() / 10.0).collect::<Vec<_>>();
|
||||
// A plain drag moves the stop.
|
||||
@@ -499,7 +504,7 @@ fn a_dropped_swatch_adds_or_recolours_a_stop() {
|
||||
let mut app = app(json!({"gradient": {}}));
|
||||
let mut p = Panel::new();
|
||||
p.frame(&mut app, vec![], Modifiers::NONE);
|
||||
let (a, b) = (p.widget(("grad-stop", 0)).center(), p.widget(("grad-stop", 1)).center());
|
||||
let (a, b) = (p.widget(("grad-stop", 0_usize)).center(), p.widget(("grad-stop", 1_usize)).center());
|
||||
let drop = |p: &mut Panel, app: &mut VectorcraftApp, at: Pos2| {
|
||||
egui::DragAndDrop::set_payload(&p.ctx, PanelDrag::paint(Paint::solid(Color::rgb(1.0, 0.0, 0.0))));
|
||||
p.frame(app, vec![Event::PointerMoved(at)], Modifiers::NONE);
|
||||
@@ -509,7 +514,7 @@ fn a_dropped_swatch_adds_or_recolours_a_stop() {
|
||||
drop(&mut p, &mut app, pos2((a.x + b.x) / 2.0, a.y - 20.0));
|
||||
assert_eq!(hexes(&app), ["#ffffff", "#ff0000", "#000000"]);
|
||||
p.frame(&mut app, vec![], Modifiers::NONE);
|
||||
let last = p.widget(("grad-stop", 2)).center();
|
||||
let last = p.widget(("grad-stop", 2_usize)).center();
|
||||
drop(&mut p, &mut app, last);
|
||||
assert_eq!(hexes(&app), ["#ffffff", "#ff0000", "#ff0000"]);
|
||||
// A spot swatch tile dropped on a stop links it: editing the swatch recolours the stop.
|
||||
@@ -538,7 +543,7 @@ fn a_color_dropped_on_the_ramp_keeps_its_model_in_a_cmyk_document() {
|
||||
app.run("paint.setFill", json!({"gradient": {"stops": rgb}, "keepModel": true})).unwrap();
|
||||
let mut p = Panel::new();
|
||||
p.frame(&mut app, vec![], Modifiers::NONE);
|
||||
let (a, b) = (p.widget(("grad-stop", 0)).center(), p.widget(("grad-stop", 1)).center());
|
||||
let (a, b) = (p.widget(("grad-stop", 0_usize)).center(), p.widget(("grad-stop", 1_usize)).center());
|
||||
let at = pos2((a.x + b.x) / 2.0, a.y - 20.0);
|
||||
let red = Color::rgb(1.0, 0.0, 0.0);
|
||||
egui::DragAndDrop::set_payload(&p.ctx, PanelDrag::paint(Paint::solid(red)));
|
||||
@@ -554,7 +559,7 @@ fn double_clicking_a_stop_opens_the_shared_popover_beside_it() {
|
||||
let mut app = app(json!({"gradient": {}}));
|
||||
let mut p = Panel::new();
|
||||
p.frame(&mut app, vec![], Modifiers::NONE);
|
||||
let stop = p.widget(("grad-stop", 1));
|
||||
let stop = p.widget(("grad-stop", 1_usize));
|
||||
let b = |pressed| Event::PointerButton { pos: stop.center(), button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
|
||||
p.frame(&mut app, vec![Event::PointerMoved(stop.center()), b(true), b(false), b(true), b(false)], Modifiers::NONE);
|
||||
let d = app.ui.dialog.clone().expect("the popover opened");
|
||||
@@ -755,7 +760,7 @@ fn a_stop_dragged_off_the_ramp_or_deleted_with_the_button_beside_it_goes() {
|
||||
let mut app = app(json!({"gradient": {"stops": stops}}));
|
||||
let mut p = Panel::new();
|
||||
p.frame(&mut app, vec![], Modifiers::NONE);
|
||||
let mid = p.widget(("grad-stop", 1)).center();
|
||||
let mid = p.widget(("grad-stop", 1_usize)).center();
|
||||
// Dragged down off the ramp, the middle stop goes.
|
||||
p.drag(&mut app, mid, mid + vec2(0.0, 60.0), Modifiers::NONE);
|
||||
assert_eq!(hexes(&app), ["#ffffff", "#000000"]);
|
||||
@@ -773,7 +778,7 @@ fn a_stop_dragged_off_the_ramp_or_deleted_with_the_button_beside_it_goes() {
|
||||
assert_eq!(hexes(&app), ["#ffffff", "#ff0000", "#000000"]);
|
||||
app.run("edit.redo", json!({})).unwrap();
|
||||
// Two stops stay two: dragged off, the stop comes back, and the button is off.
|
||||
let first = p.widget(("grad-stop", 0)).center();
|
||||
let first = p.widget(("grad-stop", 0_usize)).center();
|
||||
p.drag(&mut app, first, first + vec2(0.0, 60.0), Modifiers::NONE);
|
||||
assert_eq!(hexes(&app), ["#ffffff", "#000000"]);
|
||||
app.run("gradient.selectStop", json!({"index": 0})).unwrap();
|
||||
|
||||
@@ -176,6 +176,9 @@ struct Out {
|
||||
column: Option<Option<ColumnDrag>>,
|
||||
/// Rows to open or close, and whether to open them.
|
||||
toggle: Vec<(NodeId, bool)>,
|
||||
/// The highlighted rows after this frame's row click, when one happened: the click's action
|
||||
/// runs after `delete_key`, so `view.rows` is still the state before it.
|
||||
rows_after_click: Option<Vec<NodeId>>,
|
||||
}
|
||||
|
||||
/// The panel state that says the Layers panel's rows have the keyboard ([`super::has_row_keys`]).
|
||||
@@ -361,12 +364,17 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
// Keyboard: Delete removes the highlighted rows while the panel has the keyboard (a row was
|
||||
// clicked last, not the canvas).
|
||||
// The panel keeps it while it is drawn ([`super::has_row_keys`]); the app's shortcuts leave
|
||||
// Delete and Backspace to it meanwhile ([`super::rows_take_delete`]).
|
||||
// Delete and Backspace to it meanwhile ([`super::rows_take_delete`]). With no highlighted rows
|
||||
// left (the last one was deleted) the panel gives the keys back, so Delete clears the selected
|
||||
// art again instead of doing nothing.
|
||||
let pressed_outside = ui.input(|i| i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| !list_rect.contains(p)));
|
||||
let focus = super::has_row_keys(ui.ctx(), ROW_KEYS) && !pressed_outside;
|
||||
// A click's highlight action runs after this block, so read the rows it will leave, not the
|
||||
// snapshot `view.rows` took before it.
|
||||
let rows = out.rows_after_click.as_deref().unwrap_or(&view.rows);
|
||||
let focus = super::has_row_keys(ui.ctx(), ROW_KEYS) && !pressed_outside && !rows.is_empty();
|
||||
super::set_row_keys(ui.ctx(), ROW_KEYS, focus);
|
||||
if focus
|
||||
&& !view.rows.is_empty()
|
||||
&& !rows.is_empty()
|
||||
&& ui.ctx().memory(|m| m.focused().is_none())
|
||||
&& ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Delete) || i.consume_key(egui::Modifiers::NONE, egui::Key::Backspace))
|
||||
{
|
||||
@@ -376,6 +384,11 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
for (c, p) in out.actions {
|
||||
app.run(&c, p).ok();
|
||||
}
|
||||
// A deletion that took the last highlighted row leaves the panel nothing to delete. Release the
|
||||
// keys now, not at the next draw, so the very next frame's Delete reaches Edit › Clear.
|
||||
if app.session.active().is_some_and(|st| st.highlighted_rows().is_empty()) {
|
||||
super::set_row_keys(ui.ctx(), ROW_KEYS, false);
|
||||
}
|
||||
see_selection(app, ui, selected);
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +91,9 @@ pub(super) fn resolve_click(ui: &Ui, view: &View, list: &[Row], out: &mut Out) {
|
||||
if click.moves_anchor() {
|
||||
ui.data_mut(|d| d.insert_temp(key("anchor"), id));
|
||||
}
|
||||
let mut rows = view.rows.clone();
|
||||
widgets::apply_row_click(&mut rows, &click);
|
||||
out.rows_after_click = Some(rows);
|
||||
let ids = |rows: &[NodeId]| rows.iter().map(|r| r.0).collect::<Vec<_>>();
|
||||
out.actions.push(match click {
|
||||
widgets::RowClick::Only(id) => ("layer.setCurrent".into(), json!({"id": id.0})),
|
||||
|
||||
@@ -1348,10 +1348,15 @@ fn app_click(ctx: &egui::Context, app: &mut VectorcraftApp, pos: egui::Pos2, f:
|
||||
}
|
||||
}
|
||||
|
||||
/// `key` pressed and released in an app-like frame of `f`, then one more frame.
|
||||
fn app_key(ctx: &egui::Context, app: &mut VectorcraftApp, key: egui::Key, f: fn(&mut VectorcraftApp, &mut egui::Ui)) {
|
||||
/// `key` pressed and released in one app-like frame of `f`, with no idle frame after it.
|
||||
fn app_key_now(ctx: &egui::Context, app: &mut VectorcraftApp, key: egui::Key, f: fn(&mut VectorcraftApp, &mut egui::Ui)) {
|
||||
let ev = |pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::NONE };
|
||||
app_frame(ctx, app, vec![ev(true), ev(false)], f);
|
||||
}
|
||||
|
||||
/// `key` pressed and released in an app-like frame of `f`, then one more frame.
|
||||
fn app_key(ctx: &egui::Context, app: &mut VectorcraftApp, key: egui::Key, f: fn(&mut VectorcraftApp, &mut egui::Ui)) {
|
||||
app_key_now(ctx, app, key, f);
|
||||
app_frame(ctx, app, vec![], f);
|
||||
}
|
||||
|
||||
@@ -1481,6 +1486,53 @@ fn after_a_row_click_delete_reaches_the_layers_panel_not_edit_clear() {
|
||||
assert!(d.node(rect).is_some(), "the rectangle on Layer 1 stays");
|
||||
}
|
||||
|
||||
/// After Delete removed the last highlighted Layers row, the panel has nothing left to delete: the
|
||||
/// next Delete must go back to the app and clear the selected object, not be swallowed by the
|
||||
/// panel's row keys.
|
||||
#[test]
|
||||
fn delete_after_the_last_highlighted_layer_row_is_deleted_clears_the_selection() {
|
||||
let ctx = drop_ctx();
|
||||
let mut app = app_with_rect();
|
||||
run(&mut app, "layer.new", json!({}));
|
||||
let rect = first_selected(&app).unwrap().id;
|
||||
let texts = frame_events(&ctx, &mut app, vec![], boxed_layers);
|
||||
let row = text_rect(&texts, "Layer 2").center();
|
||||
app_click(&ctx, &mut app, row, boxed_layers);
|
||||
app_key_now(&ctx, &mut app, egui::Key::Delete, boxed_layers);
|
||||
assert_eq!(app.session.active().unwrap().doc.layers.len(), 1, "Layer 2 is deleted");
|
||||
assert!(app.session.active().unwrap().selection.contains(rect), "the rectangle stays selected");
|
||||
app_key_now(&ctx, &mut app, egui::Key::Delete, boxed_layers);
|
||||
assert!(app.session.active().unwrap().doc.node(rect).is_none(), "Delete clears the selected object once the panel has no rows");
|
||||
}
|
||||
|
||||
/// Clicking a row right after the last highlighted row was deleted restores the row keys: Delete
|
||||
/// then deletes that row, not the selected art.
|
||||
#[test]
|
||||
fn clicking_a_row_after_the_last_one_was_deleted_keeps_delete_with_the_layers_panel() {
|
||||
let ctx = drop_ctx();
|
||||
let mut app = app_with_rect();
|
||||
run(&mut app, "layer.new", json!({}));
|
||||
run(&mut app, "layer.new", json!({}));
|
||||
let rect = first_selected(&app).unwrap().id;
|
||||
let texts = frame_events(&ctx, &mut app, vec![], boxed_layers);
|
||||
let layer2 = text_rect(&texts, "Layer 2").center();
|
||||
app_click(&ctx, &mut app, layer2, boxed_layers);
|
||||
app_key_now(&ctx, &mut app, egui::Key::Delete, boxed_layers);
|
||||
assert_eq!(app.session.active().unwrap().doc.layers.len(), 2, "Layer 2 is deleted");
|
||||
// Click Layer 3 and press Delete in the very next frame: the click's highlight action runs at
|
||||
// the end of its frame, so the panel must read the rows the click will leave.
|
||||
let texts = frame_events(&ctx, &mut app, vec![], boxed_layers);
|
||||
let layer3 = text_rect(&texts, "Layer 3").center();
|
||||
let button =
|
||||
|pressed| egui::Event::PointerButton { pos: layer3, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
|
||||
for e in [egui::Event::PointerMoved(layer3), button(true), button(false)] {
|
||||
app_frame(&ctx, &mut app, vec![e], boxed_layers);
|
||||
}
|
||||
app_key_now(&ctx, &mut app, egui::Key::Delete, boxed_layers);
|
||||
assert_eq!(app.session.active().unwrap().doc.layers.len(), 1, "Delete removes the clicked row");
|
||||
assert!(app.session.active().unwrap().doc.node(rect).is_some(), "the rectangle on Layer 1 stays");
|
||||
}
|
||||
|
||||
/// A tool that claims Delete and Backspace (the Artboard tool's active artboard) leaves them to a
|
||||
/// clicked Layers row.
|
||||
#[test]
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
//! Character Styles and Paragraph Styles panels (one implementation, two kinds).
|
||||
//!
|
||||
//! Click a style to apply it to the selected text (or the Type tool's selected characters);
|
||||
//! Alt-click applies it and clears overrides. The style of the selection is highlighted, with a
|
||||
//! "+" when the text overrides some of its attributes. New makes a style from the selected text;
|
||||
//! Alt-click on New first asks for its name (`ui.newCharStyle`, `ui.newParaStyle`).
|
||||
//! Alt-click applies it and clears overrides. Double-clicking a character style's row opens its
|
||||
//! Character Style Options (`ui.charStyleOptions`), as the panel menu does; a paragraph style's row
|
||||
//! opens its Paragraph Style Options, the rename dialog the panel menu opens. The style of the
|
||||
//! selection is highlighted, with a "+" when the text overrides some of its attributes. New makes
|
||||
//! a style from the selected text; Alt-click on New first asks for its name (`ui.newCharStyle`,
|
||||
//! `ui.newParaStyle`).
|
||||
|
||||
use std::cell::RefCell;
|
||||
|
||||
@@ -225,6 +228,19 @@ fn row_look(is_cur: bool, in_highlight: bool, hovered: bool, t: &Tokens) -> (Opt
|
||||
(fill, is_cur && in_highlight)
|
||||
}
|
||||
|
||||
/// Opens `name`'s options: Character Style Options for a character style, or the Paragraph Style
|
||||
/// Options the Paragraph Styles panel menu opens, which rename the style (`ui.paramDialog` runs
|
||||
/// `paraStyle.rename`).
|
||||
fn open_style_options(app: &mut VectorcraftApp, kind: Kind, name: &str) {
|
||||
if kind == Kind::Char {
|
||||
app.run("ui.charStyleOptions", json!({ "name": name })).ok();
|
||||
} else {
|
||||
let label = format!("{} Style Options", kind.what());
|
||||
app.run("ui.paramDialog", json!({ "command": format!("{}.rename", kind.prefix()), "label": label, "params": { "name": name, "to": name } }))
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
/// The style list and its New and Delete buttons. Shift-click highlights every style from the
|
||||
/// last one clicked without Shift through this one, and Cmd/Ctrl-click adds or removes one; both
|
||||
/// leave the text as it is. Delete deletes every highlighted style but the Normal style.
|
||||
@@ -235,6 +251,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) {
|
||||
let normal = all.first().map(|s| s.0.clone()).unwrap_or_default();
|
||||
let cur_name = cur.as_ref().map(|(n, _)| n.clone().unwrap_or_else(|| normal.clone()));
|
||||
let mut clicked: Option<(String, egui::Modifiers)> = None;
|
||||
// A double-clicked Character Style's row, whose options open after the list is laid out.
|
||||
let mut options: Option<String> = None;
|
||||
// Set on a double-click's second click, which must not apply the style again.
|
||||
let mut double_click = false;
|
||||
let listed: Vec<&str> = all.iter().map(|s| s.0.as_str()).collect();
|
||||
// A highlighted style that is no longer listed (renamed in Style Options, deleted by a command
|
||||
// or undone) is no longer highlighted.
|
||||
@@ -279,6 +299,17 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) {
|
||||
if resp.clicked() {
|
||||
clicked = Some((name.clone(), ui.input(|i| i.modifiers)));
|
||||
}
|
||||
// A double-click on a user character style opens its Character Style Options, as the
|
||||
// panel menu's item does; with Shift, Cmd/Ctrl or Alt held it only follows the click
|
||||
// rules. The second click never applies the style again, also on the Normal style,
|
||||
// which has no options to open.
|
||||
if resp.double_clicked() {
|
||||
double_click = true;
|
||||
let m = ui.input(|i| i.modifiers);
|
||||
if *name != normal && !(m.shift || m.command || m.alt) {
|
||||
options = Some(name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if scroll_to.is_some() {
|
||||
@@ -287,12 +318,16 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) {
|
||||
let has_text = cur.is_some();
|
||||
if let Some((name, m)) = clicked {
|
||||
sel = super::swatches::click_selection(ui, kind.anchor_key(), sel, &listed, &name, m);
|
||||
// Shift and Cmd/Ctrl clicks only highlight, as in the Graphic Styles panel.
|
||||
if has_text && !(m.shift || m.command) {
|
||||
// Shift and Cmd/Ctrl clicks only highlight, as in the Graphic Styles panel. The second
|
||||
// click of a double-click opens the options instead of applying the style again.
|
||||
if has_text && !(m.shift || m.command) && options.is_none() && !double_click {
|
||||
let p = apply_target(app, kind, json!({ "name": name, "clearOverrides": m.alt }));
|
||||
app.run(&format!("{}.apply", kind.prefix()), p).ok();
|
||||
}
|
||||
}
|
||||
if let Some(name) = options {
|
||||
open_style_options(app, kind, &name);
|
||||
}
|
||||
set_highlighted(ui.ctx(), kind, sel.clone());
|
||||
let doomed: Vec<&String> = sel.iter().filter(|s| **s != normal).collect();
|
||||
let deletable = !doomed.is_empty();
|
||||
@@ -389,12 +424,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) {
|
||||
if menu_item(ui, &crate::i18n::fmt(tl!("{kind} Style Options…"), &[("kind", kind.what_label())]), user, false)
|
||||
&& let Some(n) = &sel
|
||||
{
|
||||
if kind == Kind::Char {
|
||||
app.run("ui.charStyleOptions", json!({ "name": n })).ok();
|
||||
} else {
|
||||
let label = format!("{} Style Options", kind.what());
|
||||
app.run("ui.paramDialog", json!({ "command": format!("{p}.rename"), "label": label, "params": { "name": n, "to": n } })).ok();
|
||||
}
|
||||
open_style_options(app, kind, n);
|
||||
}
|
||||
ui.separator();
|
||||
// As commands, so a file dialog shown off the UI thread loads the styles when it answers.
|
||||
|
||||
@@ -261,6 +261,66 @@ fn click_label(ctx: &egui::Context, app: &mut VectorcraftApp, label: &str, mut d
|
||||
app.run_count.wrapping_sub(before)
|
||||
}
|
||||
|
||||
/// Double-clicking a user character style's row opens its Character Style Options once: the first
|
||||
/// click applies the style to the selected text, the second opens the dialog without applying it
|
||||
/// again. A paragraph style's row opens none, and the built-in Normal style opens none either.
|
||||
#[test]
|
||||
fn double_clicking_a_character_style_row_opens_its_options() {
|
||||
for kind in [Kind::Char, Kind::Para] {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 150})).unwrap();
|
||||
let id = app.run("text.create", json!({"x": 20, "y": 40, "text": "Hi"})).unwrap()["id"].clone();
|
||||
app.run("select.set", json!({"ids": [id]})).unwrap();
|
||||
app.run(&format!("{}.new", kind.prefix()), json!({"name": "Body"})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let mut draw = |app: &mut VectorcraftApp, ui: &mut egui::Ui| show(app, ui, kind);
|
||||
frame_events(&ctx, &mut app, vec![], &mut draw);
|
||||
let texts = frame_events(&ctx, &mut app, vec![], &mut draw);
|
||||
let pos = text_rect(&texts, "Body").center();
|
||||
let before = app.run_count;
|
||||
click(&ctx, &mut app, pos, &mut draw);
|
||||
click(&ctx, &mut app, pos, &mut draw);
|
||||
let dialog = app.ui.dialog.as_ref();
|
||||
if kind == Kind::Char {
|
||||
let d = dialog.expect("double-clicking a Character Style's row opens its options");
|
||||
assert_eq!(d.kind, "charStyleOptions");
|
||||
assert_eq!(d.str("__style"), "Body");
|
||||
// One apply from the first click and the dialog from the second: no second apply.
|
||||
assert_eq!(app.run_count.wrapping_sub(before), 2, "one apply and the dialog");
|
||||
} else {
|
||||
let d = dialog.expect("double-clicking a Paragraph Style's row opens its options");
|
||||
assert_eq!(d.kind, "command");
|
||||
assert_eq!(d.str("__command"), "paraStyle.rename");
|
||||
assert_eq!(d.str("__label"), "Paragraph Style Options");
|
||||
// One apply from the first click and the rename dialog from the second.
|
||||
assert_eq!(app.run_count.wrapping_sub(before), 2, "one apply and the dialog");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A double-click on the built-in Normal style opens no options, and its second click applies it
|
||||
/// only once, as a user style's does.
|
||||
#[test]
|
||||
fn double_clicking_the_normal_style_opens_no_options() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 150})).unwrap();
|
||||
let id = app.run("text.create", json!({"x": 20, "y": 40, "text": "Hi"})).unwrap()["id"].clone();
|
||||
app.run("select.set", json!({"ids": [id]})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let mut draw = |app: &mut VectorcraftApp, ui: &mut egui::Ui| show(app, ui, Kind::Char);
|
||||
frame_events(&ctx, &mut app, vec![], &mut draw);
|
||||
let texts = frame_events(&ctx, &mut app, vec![], &mut draw);
|
||||
let normal = texts.iter().find(|(text, _)| text.contains("Normal")).map(|(_, r)| r.center());
|
||||
let pos = normal.expect("the Normal style's row is drawn");
|
||||
let before = app.run_count;
|
||||
click(&ctx, &mut app, pos, &mut draw);
|
||||
click(&ctx, &mut app, pos, &mut draw);
|
||||
assert!(app.ui.dialog.is_none(), "the Normal style has no Character Style Options");
|
||||
assert_eq!(app.run_count.wrapping_sub(before), 1, "the second click applies nothing");
|
||||
}
|
||||
|
||||
/// The panel menu's Duplicate highlights the copy and its Delete clears the highlight, as the
|
||||
/// panel's New and Delete buttons do; after a menu delete, Duplicate runs nothing.
|
||||
#[test]
|
||||
|
||||
@@ -511,10 +511,13 @@ fn category_fields(ui: &mut egui::Ui, app: &mut VectorcraftApp, d: &mut Dialog,
|
||||
}
|
||||
PrefKind::Bool => bool_row(ui, d, sp.key, sp.label),
|
||||
PrefKind::Num { min, max, unit } => {
|
||||
labeled(ui, sp.label, |ui| {
|
||||
labeled(ui, sp.label, |ui, label_id| {
|
||||
let mut x = v.as_f64().unwrap_or(min);
|
||||
let new = if sp.key == "uiScaling" {
|
||||
widgets::slider(ui, egui::Slider::new(&mut x, min..=max).step_by(0.05).text(tl!("Smaller ↔ Larger"))).changed().then_some(x)
|
||||
// Without internal text, both slider controls inherit the row's name across focus changes.
|
||||
let response = widgets::slider(ui, egui::Slider::new(&mut x, min..=max).step_by(0.05)).labelled_by(label_id);
|
||||
ui.add(egui::Label::new(tl!("Smaller ↔ Larger")).wrap_mode(egui::TextWrapMode::Extend));
|
||||
response.changed().then_some(x)
|
||||
} else {
|
||||
widgets::range_field(ui, sp.key, x, min..=max, &format!(" {unit}"), 3, 110.0)
|
||||
};
|
||||
@@ -527,17 +530,19 @@ fn category_fields(ui: &mut egui::Ui, app: &mut VectorcraftApp, d: &mut Dialog,
|
||||
// In the dialog's own Units choice, so a change there shows right away.
|
||||
let unit = vectorcraft_doc::Unit::named(&d.str(measure.pref_key())).unwrap_or_default();
|
||||
let x = d.f64(sp.key, min);
|
||||
labeled(ui, sp.label, |ui| {
|
||||
labeled(ui, sp.label, |ui, _| {
|
||||
if let Some(x) = widgets::num_field(ui, sp.key, Some(x), unit, 110.0) {
|
||||
d.fields.insert(sp.key.into(), json!(x.clamp(min, max)));
|
||||
}
|
||||
});
|
||||
}
|
||||
PrefKind::Int { min, max } => {
|
||||
labeled(ui, sp.label, |ui| {
|
||||
labeled(ui, sp.label, |ui, label_id| {
|
||||
let mut x = v.as_i64().unwrap_or(min);
|
||||
let new = if sp.key == "anchorSize" {
|
||||
widgets::slider(ui, egui::Slider::new(&mut x, min..=max).show_value(false).text(tl!("Size"))).changed().then_some(x)
|
||||
let response = widgets::slider(ui, egui::Slider::new(&mut x, min..=max).show_value(false)).labelled_by(label_id);
|
||||
ui.add(egui::Label::new(tl!("Size")).wrap_mode(egui::TextWrapMode::Extend));
|
||||
response.changed().then_some(x)
|
||||
} else {
|
||||
widgets::range_field(ui, sp.key, x as f64, min as f64..=max as f64, "", 0, 110.0).map(|x| x as i64)
|
||||
};
|
||||
@@ -569,11 +574,11 @@ fn category_fields(ui: &mut egui::Ui, app: &mut VectorcraftApp, d: &mut Dialog,
|
||||
}
|
||||
}
|
||||
PrefKind::Color => {
|
||||
labeled(ui, sp.label, |ui| {
|
||||
labeled(ui, sp.label, |ui, label_id| {
|
||||
let hex = v.as_str().unwrap_or("#000000");
|
||||
let c = vectorcraft_color::Color::from_hex(hex).map(|c| c.to_rgba8(1.0)).unwrap_or([0, 0, 0, 255]);
|
||||
let mut rgb = [c[0], c[1], c[2]];
|
||||
if ui.color_edit_button_srgb(&mut rgb).changed() {
|
||||
if ui.color_edit_button_srgb(&mut rgb).labelled_by(label_id).changed() {
|
||||
d.fields.insert(sp.key.into(), json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2])));
|
||||
}
|
||||
ui.label(egui::RichText::new(hex).color(t.text_dim).size(11.0));
|
||||
@@ -581,7 +586,7 @@ fn category_fields(ui: &mut egui::Ui, app: &mut VectorcraftApp, d: &mut Dialog,
|
||||
}
|
||||
PrefKind::Text if sp.key == "interfaceLanguage" => {
|
||||
// `auto` plus every registered language, by its own name.
|
||||
labeled(ui, sp.label, |ui| {
|
||||
labeled(ui, sp.label, |ui, _| {
|
||||
let cur = v.as_str().unwrap_or("auto");
|
||||
let codes: Vec<&str> = std::iter::once("auto").chain(crate::i18n::Lang::for_picker().into_iter().map(|l| l.code())).collect();
|
||||
let labels: Vec<&str> =
|
||||
@@ -598,7 +603,7 @@ fn category_fields(ui: &mut egui::Ui, app: &mut VectorcraftApp, d: &mut Dialog,
|
||||
// Plug-ins Folder: typed, or picked with Choose… where the host has a folder picker
|
||||
// (not on the web).
|
||||
PrefKind::Text if folder_check(sp.key).is_some() => {
|
||||
labeled(ui, sp.label, |ui| {
|
||||
labeled(ui, sp.label, |ui, _| {
|
||||
let mut s = v.as_str().unwrap_or("").to_string();
|
||||
if ui.add(egui::TextEdit::singleline(&mut s).align(egui::Align2::LEFT_CENTER).desired_width(200.0)).changed() {
|
||||
d.fields.insert(sp.key.into(), json!(s));
|
||||
@@ -609,7 +614,7 @@ fn category_fields(ui: &mut egui::Ui, app: &mut VectorcraftApp, d: &mut Dialog,
|
||||
});
|
||||
}
|
||||
PrefKind::Text => {
|
||||
labeled(ui, sp.label, |ui| {
|
||||
labeled(ui, sp.label, |ui, _| {
|
||||
let mut s = v.as_str().unwrap_or("").to_string();
|
||||
if ui.add(egui::TextEdit::singleline(&mut s).align(egui::Align2::LEFT_CENTER).desired_width(260.0)).changed() {
|
||||
d.fields.insert(sp.key.into(), json!(s));
|
||||
@@ -644,7 +649,7 @@ fn picas_in_use(d: &Dialog) -> bool {
|
||||
}
|
||||
|
||||
fn choice_row(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, opts: &[(&str, &str)], v: &Value) {
|
||||
labeled(ui, label, |ui| {
|
||||
labeled(ui, label, |ui, _| {
|
||||
let cur = v.as_str().unwrap_or("");
|
||||
let cur_label = opts.iter().find(|o| o.0 == cur).map(|o| o.1).unwrap_or(cur);
|
||||
let labels: Vec<&str> = opts.iter().map(|o| o.1).collect();
|
||||
@@ -664,14 +669,16 @@ fn bool_row(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
fn labeled(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui)) {
|
||||
fn labeled(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui, egui::Id)) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.horizontal(|ui| {
|
||||
ui.allocate_ui_with_layout(egui::vec2(210.0, 22.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
ui.set_min_width(210.0);
|
||||
ui.add(egui::Label::new(egui::RichText::new(format!("{}{}", tl!(label), tl!(":"))).color(t.text)).truncate());
|
||||
});
|
||||
add(ui);
|
||||
let label = ui
|
||||
.allocate_ui_with_layout(egui::vec2(210.0, 22.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
ui.set_min_width(210.0);
|
||||
ui.add(egui::Label::new(egui::RichText::new(format!("{}{}", tl!(label), tl!(":"))).color(t.text)).truncate())
|
||||
})
|
||||
.inner;
|
||||
ui.label_inputs_by(label.id, |ui| add(ui, label.id));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -955,6 +955,95 @@ fn ok_refuses_a_typed_folder_that_choose_would_refuse() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Preferences form rows associate each input with its visible label for accessibility tools.
|
||||
#[test]
|
||||
fn preference_inputs_expose_their_visible_labels() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut app = app();
|
||||
open(&mut app, Some("General"));
|
||||
let mut fonts = false;
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 700.0)).with_accessibility_check(false).build_ui_state(
|
||||
move |ui, app: &mut VectorcraftApp| {
|
||||
if !std::mem::replace(&mut fonts, true) {
|
||||
crate::theme::install_fonts(ui.ctx());
|
||||
return;
|
||||
}
|
||||
show(app, ui.ctx());
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(3);
|
||||
let _ = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Keyboard Increment:");
|
||||
}
|
||||
|
||||
/// Render the real category without the dialog's scroll viewport clipping later rows.
|
||||
fn preference_category_harness(category: &'static str) -> egui_kittest::Harness<'static, (VectorcraftApp, Dialog)> {
|
||||
let mut app = app();
|
||||
app.services.pick_folder = Some(Box::new(|| None));
|
||||
open(&mut app, Some(category));
|
||||
let dialog = app.ui.dialog.take().unwrap();
|
||||
let mut fonts = false;
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 1400.0)).with_accessibility_check(false).build_ui_state(
|
||||
move |ui, (app, dialog): &mut (VectorcraftApp, Dialog)| {
|
||||
if !std::mem::replace(&mut fonts, true) {
|
||||
crate::theme::install_fonts(ui.ctx());
|
||||
return;
|
||||
}
|
||||
category_fields(ui, app, dialog, category);
|
||||
},
|
||||
(app, dialog),
|
||||
);
|
||||
h.run_steps(3);
|
||||
h
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preference_colour_well_exposes_its_row_label_and_value() {
|
||||
use egui_kittest::kittest::{NodeT, Queryable};
|
||||
let h = preference_category_harness("Guides & Grid");
|
||||
let expected = format!("{}ff", h.state().1.str("patternTileEdgeColor").to_lowercase());
|
||||
let colour = h.get_by_role_and_label(egui::accesskit::Role::ColorWell, "Tile Edge Color:");
|
||||
assert_eq!(colour.accesskit_node().value().as_deref(), Some(expected.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preference_scale_labels_both_controls_across_focus_changes() {
|
||||
use egui::accesskit::Role;
|
||||
use egui_kittest::kittest::{NodeT, Queryable};
|
||||
let mut h = preference_category_harness("User Interface");
|
||||
let expected = h.state().1.f64("uiScaling", 1.0);
|
||||
for focus in [None, Some(Role::SpinButton), Some(Role::Slider)] {
|
||||
if let Some(role) = focus {
|
||||
h.get_by_role_and_label(role, "Scale:").focus();
|
||||
h.run_steps(3);
|
||||
assert!(h.get_by_role_and_label(role, "Scale:").accesskit_node().is_focused());
|
||||
}
|
||||
for role in [Role::Slider, Role::SpinButton] {
|
||||
let control = h.get_by_role_and_label(role, "Scale:");
|
||||
assert_eq!(control.accesskit_node().numeric_value(), Some(expected));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preference_anchor_slider_exposes_its_row_label_and_value() {
|
||||
use egui::accesskit::Role;
|
||||
use egui_kittest::kittest::{NodeT, Queryable};
|
||||
let h = preference_category_harness("Selection & Anchor Display");
|
||||
let slider = h.get_by_role_and_label(Role::Slider, "Size:");
|
||||
assert_eq!(slider.accesskit_node().numeric_value(), Some(h.state().1.f64("anchorSize", 3.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preference_folder_label_preserves_the_choose_action_name() {
|
||||
use egui::accesskit::Role;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let h = preference_category_harness("File Handling");
|
||||
let _ = h.get_by_role_and_label(Role::TextInput, "Folder:");
|
||||
let _ = h.get_by_role_and_label(Role::Button, "Choose…");
|
||||
assert!(h.query_by_role_and_label(Role::Button, "Folder:").is_none());
|
||||
}
|
||||
|
||||
/// Under automation roots, OK refuses a typed folder outside them before it looks at the folder.
|
||||
#[test]
|
||||
fn ok_refuses_a_typed_folder_outside_the_automation_roots_before_looking_at_it() {
|
||||
|
||||
@@ -27,7 +27,7 @@ pub(crate) fn keep_marked_text(app: &mut VectorcraftApp) {
|
||||
app.ime_discard = true;
|
||||
if app.session.tool_composing() {
|
||||
// Errors are already reported by the engine's interaction.
|
||||
let _ = app.session.tool_text(&m, app.view_info());
|
||||
let _ = app.session.tool_typed_text(&m, app.view_info());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,7 +260,7 @@ fn apply_type_event(
|
||||
Seq::Text(t) if !app.session.tool_composing() && !shortcut_mods => {
|
||||
let target_before = crate::ime::target(&mut app.session, view);
|
||||
let before = crate::ime::stamp(&mut app.session, view);
|
||||
let r = app.session.tool_text(&t, view);
|
||||
let r = app.session.tool_typed_text(&t, view);
|
||||
if r.is_ok() {
|
||||
crate::ime::note_application_edit(app, ctx, view, target_before, before);
|
||||
}
|
||||
@@ -283,7 +283,7 @@ fn apply_type_event(
|
||||
let composing = app.session.tool_composing();
|
||||
let verbatim = app.session.tool_ime_session_matches(view)
|
||||
&& match app.ime_marked.as_deref() {
|
||||
Some(m) => app.session.tool_text_is_verbatim(m, view),
|
||||
Some(m) => app.session.tool_typed_text_is_verbatim(m, view),
|
||||
None => true,
|
||||
};
|
||||
keep_marked_text(app);
|
||||
@@ -306,8 +306,8 @@ fn apply_type_event(
|
||||
let before = crate::ime::stamp(&mut app.session, view);
|
||||
// A document edit since the composition means this commit starts a fresh typing
|
||||
// session: that reset is application-side, so it cannot earn `InputMethod`.
|
||||
let verbatim = app.session.tool_ime_session_matches(view) && app.session.tool_text_is_verbatim(&t, view);
|
||||
let r = app.session.tool_text(&t, view);
|
||||
let verbatim = app.session.tool_ime_session_matches(view) && app.session.tool_typed_text_is_verbatim(&t, view);
|
||||
let r = app.session.tool_typed_text(&t, view);
|
||||
if r.is_ok() {
|
||||
if verbatim {
|
||||
crate::ime::note_ime_apply(app, ctx, view, target_before, before);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user