Merge main into fix/fitted-gradient-turns-with-transform

This commit is contained in:
dotCooCoo
2026-11-08 12:09:50 -08:00
111 changed files with 3772 additions and 307 deletions
Generated
+1
View File
@@ -6338,6 +6338,7 @@ dependencies = [
"memchr",
"ruzstd",
"serde_json",
"skrifa 0.47.0",
"vectorcraft-brush",
"vectorcraft-color",
"vectorcraft-doc",
+1 -1
View File
@@ -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_")?))
+7
View File
@@ -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);
}
+152 -39
View File
@@ -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);
}
}
+40 -2
View File
@@ -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")));
}
}
+5 -4
View File
@@ -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);
+8 -1
View File
@@ -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)
+15 -4
View File
@@ -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;
+30 -1
View File
@@ -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");
}
}
+21
View File
@@ -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“");
+103 -19
View File
@@ -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
}
+110
View File
@@ -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 }))
+4 -1
View File
@@ -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| {
+3 -1
View File
@@ -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::{
+29 -2
View File
@@ -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)
+4 -4
View File
@@ -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
+147 -3
View File
@@ -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]
+19 -1
View File
@@ -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}");
}
}
}
+18
View File
@@ -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('°'));
}
}
+11
View File
@@ -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 {
+3
View File
@@ -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,
}
}
+62 -3
View File
@@ -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();
+81
View File
@@ -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
+29
View File
@@ -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.
+4
View File
@@ -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"})),
+47
View File
@@ -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
+2
View File
@@ -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).
+3
View File
@@ -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;
+56 -5
View File
@@ -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]);
+17 -3
View File
@@ -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))
}
+59 -24
View File
@@ -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);
+50
View File
@@ -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));
}
+176
View File
@@ -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() {
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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(());
}
+21 -4
View File
@@ -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(())
+18 -1
View File
@@ -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
+253
View File
@@ -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
View File
@@ -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();
+89
View File
@@ -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();
}
}
+21
View File
@@ -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.
+10 -1
View File
@@ -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)?;
+9
View File
@@ -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));
}
+10 -4
View File
@@ -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();
+8
View File
@@ -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'")]);
}
+3 -3
View File
@@ -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();
+2 -2
View File
@@ -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);
}
+198 -3
View File
@@ -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
}
+404
View File
@@ -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);
}
+4
View File
@@ -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.
+8 -2
View File
@@ -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
+67 -5
View File
@@ -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)));
+1
View File
@@ -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,
+20 -5
View File
@@ -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)
}
+8 -1
View File
@@ -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())
}
+1
View File
@@ -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),
},
+4
View File
@@ -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.
+37 -1
View File
@@ -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);
+54
View File
@@ -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]
+322 -29
View File
@@ -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 [
+8 -6
View File
@@ -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()) {
+71 -1
View File
@@ -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");
}
+94 -10
View File
@@ -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() {
+1
View File
@@ -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.
+1
View File
@@ -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.
+1
View File
@@ -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.
+1
View File
@@ -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.
+1
View File
@@ -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.
+1
View File
@@ -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.
+1
View File
@@ -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.
+1
View File
@@ -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.
+1
View File
@@ -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.
+1
View File
@@ -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.
+1
View File
@@ -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.
+1
View File
@@ -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.
+1
View File
@@ -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.
+1
View File
@@ -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.
+2
View File
@@ -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.
+1
View File
@@ -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.
+1
View File
@@ -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.
+1
View File
@@ -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.
+1
View File
@@ -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.
+1
View File
@@ -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.
+1
View File
@@ -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.
+1
View File
@@ -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.
+1
View File
@@ -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.
+1
View File
@@ -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.
+1
View File
@@ -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.
+1
View File
@@ -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.
+27 -22
View File
@@ -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();
+16 -3
View File
@@ -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);
}
+3
View File
@@ -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})),
+54 -2
View File
@@ -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]
+41 -11
View File
@@ -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]
+24 -17
View File
@@ -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));
});
}
+89
View File
@@ -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() {
+5 -5
View File
@@ -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