mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-09 14:14:11 +00:00
Merge main into fix/plugin-grant-module-hash
This commit is contained in:
Generated
+1
@@ -6338,6 +6338,7 @@ dependencies = [
|
||||
"memchr",
|
||||
"ruzstd",
|
||||
"serde_json",
|
||||
"skrifa 0.47.0",
|
||||
"vectorcraft-brush",
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
|
||||
@@ -310,7 +310,7 @@ fn pci_ids(device_id: &str) -> Option<(u32, u32)> {
|
||||
let field = |key: &str| {
|
||||
device_id.split(['\\', '&']).find_map(|part| {
|
||||
let (name, hex) = part.split_at_checked(key.len())?;
|
||||
if name.eq_ignore_ascii_case(key) { u32::from_str_radix(hex, 16).ok() } else { None }
|
||||
if name.eq_ignore_ascii_case(key) && hex.bytes().all(|b| b.is_ascii_hexdigit()) { u32::from_str_radix(hex, 16).ok() } else { None }
|
||||
})
|
||||
};
|
||||
Some((field("VEN_")?, field("DEV_")?))
|
||||
|
||||
@@ -820,3 +820,10 @@ fn crashes_are_forgotten_in_another_version_or_under_another_preference() {
|
||||
assert_eq!(record.crashed.len(), CRASHED_LIMIT);
|
||||
assert_eq!(record.crashed.last().map(|c| c.key.as_str()), Some("Dx12:10de:0013"));
|
||||
}
|
||||
|
||||
/// `u32::from_str_radix` accepts a leading `+`; a device id with one is not a PCI id.
|
||||
#[test]
|
||||
fn pci_ids_reject_a_sign_in_the_hex_digits() {
|
||||
assert_eq!(pci_ids("PCI\\VEN_+10DE&DEV_2204"), None);
|
||||
assert_eq!(pci_ids("PCI\\VEN_10DE&DEV_+2204"), None);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,13 @@
|
||||
//! same undo step as the typing round it. The keypad digits themselves are taken out of the frame,
|
||||
//! so they don't run a shortcut bound to Alt and a digit.
|
||||
//!
|
||||
//! With NumLock off the keypad keys arrive as Insert, End, Down, Page Down, Left, Clear, Right,
|
||||
//! Home, Up and Page Up, and Windows still composes the code from them. They are told from the
|
||||
//! separate navigation keys by the extended-key flag, which only the separate keys set
|
||||
//! (<https://learn.microsoft.com/en-us/windows/win32/inputdev/about-keyboard-input#extended-key-flag>),
|
||||
//! so with Alt held they count as digits and are taken out of the frame too, rather than moving
|
||||
//! the caret before the character arrives.
|
||||
//!
|
||||
//! egui reports a keypad digit as the same key as the digit above the letters, so the other
|
||||
//! platforms, whose systems have no such convention, can't tell them apart either.
|
||||
|
||||
@@ -28,10 +35,20 @@ const WM_CHAR: u32 = 0x0102;
|
||||
const WM_SYSKEYDOWN: u32 = 0x0104;
|
||||
const WM_SYSKEYUP: u32 = 0x0105;
|
||||
const WM_SYSCHAR: u32 = 0x0106;
|
||||
/// `VK_NUMPAD0` to `VK_NUMPAD9` (`winuser.h`): keypad digits with NumLock on. With it off the keys
|
||||
/// are Insert, End, … and type no code.
|
||||
/// `VK_NUMPAD0` to `VK_NUMPAD9` (`winuser.h`): keypad digits with NumLock on.
|
||||
const VK_NUMPAD0: usize = 0x60;
|
||||
const VK_NUMPAD9: usize = 0x69;
|
||||
/// The keypad's virtual keys with NumLock off, by digit (`winuser.h`): `VK_INSERT`, `VK_END`,
|
||||
/// `VK_DOWN`, `VK_NEXT`, `VK_LEFT`, `VK_CLEAR`, `VK_RIGHT`, `VK_HOME`, `VK_UP` and `VK_PRIOR`.
|
||||
const NUMLOCK_OFF_KEYS: [usize; 10] = [0x2D, 0x23, 0x28, 0x22, 0x25, 0x0C, 0x27, 0x24, 0x26, 0x21];
|
||||
/// The keys egui reports for the keypad with NumLock off, by digit (Clear has no egui key, so it
|
||||
/// comes as the top-row digit key, `Num5`).
|
||||
const NUMLOCK_OFF_EGUI_KEYS: [Key; 10] =
|
||||
[Key::Insert, Key::End, Key::ArrowDown, Key::PageDown, Key::ArrowLeft, Key::Num5, Key::ArrowRight, Key::Home, Key::ArrowUp, Key::PageUp];
|
||||
const DIGIT_KEYS: [Key; 10] = [Key::Num0, Key::Num1, Key::Num2, Key::Num3, Key::Num4, Key::Num5, Key::Num6, Key::Num7, Key::Num8, Key::Num9];
|
||||
/// Bit 24 of a key message's `lParam`: an extended key, such as the navigation keys beside the
|
||||
/// keypad (the keypad's own keys clear it).
|
||||
const EXTENDED_KEY: isize = 1 << 24;
|
||||
/// `VK_MENU`, `VK_LMENU` and `VK_RMENU`: Alt.
|
||||
const ALT_KEYS: [usize; 3] = [0x12, 0xA4, 0xA5];
|
||||
|
||||
@@ -51,23 +68,42 @@ struct AltCodes {
|
||||
high: Option<u16>,
|
||||
/// Characters the codes typed, for the next frame.
|
||||
typed: String,
|
||||
/// Keypad digit presses and releases with Alt held since the last frame, which egui reports
|
||||
/// as Alt and a digit: taken out of the frame.
|
||||
keypad_events: usize,
|
||||
/// The digits of the keypad presses and releases with Alt held since the last frame, which
|
||||
/// egui reports as Alt and a digit (or, with NumLock off, Alt and a navigation key): taken
|
||||
/// out of the frame.
|
||||
keypad_events: Vec<usize>,
|
||||
}
|
||||
|
||||
/// The digit a keypad key stands for in an Alt code: `wparam` is its virtual-key code and
|
||||
/// `lparam` the message's flags.
|
||||
fn keypad_digit(wparam: usize, lparam: isize) -> Option<usize> {
|
||||
if (VK_NUMPAD0..=VK_NUMPAD9).contains(&wparam) {
|
||||
return Some(wparam - VK_NUMPAD0);
|
||||
}
|
||||
if lparam & EXTENDED_KEY != 0 {
|
||||
return None;
|
||||
}
|
||||
NUMLOCK_OFF_KEYS.iter().position(|&k| k == wparam)
|
||||
}
|
||||
|
||||
impl AltCodes {
|
||||
/// Note a keyboard message `message` with `wparam` (the virtual-key code, or a UTF-16 unit).
|
||||
fn message(&mut self, message: u32, wparam: usize) {
|
||||
let keypad = (VK_NUMPAD0..=VK_NUMPAD9).contains(&wparam);
|
||||
match message {
|
||||
/// Note a keyboard message `message` with `wparam` (the virtual-key code, or a UTF-16 unit)
|
||||
/// and `lparam` (its flags).
|
||||
fn message(&mut self, message: u32, wparam: usize, lparam: isize) {
|
||||
let keypad = match message {
|
||||
WM_SYSKEYDOWN | WM_SYSKEYUP => keypad_digit(wparam, lparam),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(d) = keypad {
|
||||
// Windows sends a key pressed with Alt held as a system key.
|
||||
WM_SYSKEYDOWN if keypad => {
|
||||
if message == WM_SYSKEYDOWN {
|
||||
self.digits += 1;
|
||||
self.keypad_events += 1;
|
||||
self.armed = false;
|
||||
}
|
||||
WM_SYSKEYUP if keypad => self.keypad_events += 1,
|
||||
self.keypad_events.push(d);
|
||||
return;
|
||||
}
|
||||
match message {
|
||||
WM_KEYDOWN | WM_SYSKEYDOWN if ALT_KEYS.contains(&wparam) => {}
|
||||
// Any other key ends the sequence and keeps its own meaning.
|
||||
WM_KEYDOWN | WM_SYSKEYDOWN => self.reset(),
|
||||
@@ -99,14 +135,23 @@ impl AltCodes {
|
||||
/// out the keypad digits typed with Alt held.
|
||||
fn drain(&mut self, events: &mut Vec<Event>) {
|
||||
let mut keypad = std::mem::take(&mut self.keypad_events);
|
||||
if keypad > 0 {
|
||||
if !keypad.is_empty() {
|
||||
events.retain(|e| {
|
||||
let digit = matches!(e, Event::Key { key: Key::Num0 | Key::Num1 | Key::Num2 | Key::Num3 | Key::Num4 | Key::Num5 | Key::Num6 | Key::Num7 | Key::Num8 | Key::Num9, modifiers, .. } if modifiers.alt);
|
||||
if digit && keypad > 0 {
|
||||
keypad -= 1;
|
||||
return false;
|
||||
let Event::Key { key, modifiers, .. } = e else {
|
||||
return true;
|
||||
};
|
||||
if !modifiers.alt {
|
||||
return true;
|
||||
}
|
||||
// The first keypad key still to take out that egui reports as `key`.
|
||||
let stands_for = |d: &usize| DIGIT_KEYS.get(*d) == Some(key) || NUMLOCK_OFF_EGUI_KEYS.get(*d) == Some(key);
|
||||
match keypad.iter().position(stands_for) {
|
||||
Some(i) => {
|
||||
keypad.remove(i);
|
||||
false
|
||||
}
|
||||
None => true,
|
||||
}
|
||||
true
|
||||
});
|
||||
}
|
||||
if !self.typed.is_empty() {
|
||||
@@ -129,7 +174,7 @@ pub fn msg_hook(msg: *const std::ffi::c_void) {
|
||||
if (WM_KEYDOWN..=WM_SYSCHAR).contains(&msg.message) {
|
||||
CODES.with(|c| {
|
||||
if let Ok(mut c) = c.try_borrow_mut() {
|
||||
c.message(msg.message, msg.wParam.0);
|
||||
c.message(msg.message, msg.wParam.0, msg.lParam.0);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -152,14 +197,14 @@ mod tests {
|
||||
|
||||
/// Alt held, keypad digits `code`, Alt released, and the character Windows then posts.
|
||||
fn alt_code(c: &mut AltCodes, code: &str, posted: u16) {
|
||||
c.message(WM_SYSKEYDOWN, ALT);
|
||||
c.message(WM_SYSKEYDOWN, ALT, 0);
|
||||
for d in code.bytes() {
|
||||
let vk = VK_NUMPAD0 + usize::from(d - b'0');
|
||||
c.message(WM_SYSKEYDOWN, vk);
|
||||
c.message(WM_SYSKEYUP, vk);
|
||||
c.message(WM_SYSKEYDOWN, vk, 0);
|
||||
c.message(WM_SYSKEYUP, vk, 0);
|
||||
}
|
||||
c.message(WM_KEYUP, ALT);
|
||||
c.message(WM_CHAR, usize::from(posted));
|
||||
c.message(WM_KEYUP, ALT, 0);
|
||||
c.message(WM_CHAR, usize::from(posted), 0);
|
||||
}
|
||||
|
||||
fn key(key: Key, alt: bool, pressed: bool) -> Event {
|
||||
@@ -186,11 +231,11 @@ mod tests {
|
||||
fn a_character_without_a_code_before_it_is_left_to_winit() {
|
||||
let mut c = AltCodes::default();
|
||||
// A plain key press's character (winit types it), and Alt pressed and released alone.
|
||||
c.message(WM_KEYDOWN, 0x41);
|
||||
c.message(WM_CHAR, usize::from(b'a'));
|
||||
c.message(WM_SYSKEYDOWN, ALT);
|
||||
c.message(WM_KEYUP, ALT);
|
||||
c.message(WM_CHAR, usize::from(b'b'));
|
||||
c.message(WM_KEYDOWN, 0x41, 0);
|
||||
c.message(WM_CHAR, usize::from(b'a'), 0);
|
||||
c.message(WM_SYSKEYDOWN, ALT, 0);
|
||||
c.message(WM_KEYUP, ALT, 0);
|
||||
c.message(WM_CHAR, usize::from(b'b'), 0);
|
||||
let mut events = vec![];
|
||||
c.drain(&mut events);
|
||||
assert!(events.is_empty(), "{events:?}");
|
||||
@@ -199,12 +244,12 @@ mod tests {
|
||||
#[test]
|
||||
fn another_key_while_alt_is_held_cancels_the_code() {
|
||||
let mut c = AltCodes::default();
|
||||
c.message(WM_SYSKEYDOWN, ALT);
|
||||
c.message(WM_SYSKEYDOWN, VK_NUMPAD0 + 1);
|
||||
c.message(WM_SYSKEYDOWN, ALT, 0);
|
||||
c.message(WM_SYSKEYDOWN, VK_NUMPAD0 + 1, 0);
|
||||
// Alt+F: a shortcut, not a code.
|
||||
c.message(WM_SYSKEYDOWN, 0x46);
|
||||
c.message(WM_KEYUP, ALT);
|
||||
c.message(WM_CHAR, 0x41);
|
||||
c.message(WM_SYSKEYDOWN, 0x46, 0);
|
||||
c.message(WM_KEYUP, ALT, 0);
|
||||
c.message(WM_CHAR, 0x41, 0);
|
||||
let mut events = vec![];
|
||||
c.drain(&mut events);
|
||||
assert!(!events.iter().any(|e| matches!(e, Event::Text(_))), "{events:?}");
|
||||
@@ -213,13 +258,81 @@ mod tests {
|
||||
#[test]
|
||||
fn a_code_above_the_basic_plane_arrives_as_two_halves() {
|
||||
let mut c = AltCodes::default();
|
||||
c.message(WM_SYSKEYDOWN, ALT);
|
||||
c.message(WM_SYSKEYDOWN, VK_NUMPAD0 + 1);
|
||||
c.message(WM_KEYUP, ALT);
|
||||
c.message(WM_CHAR, 0xD83D);
|
||||
c.message(WM_CHAR, 0xDE00);
|
||||
c.message(WM_SYSKEYDOWN, ALT, 0);
|
||||
c.message(WM_SYSKEYDOWN, VK_NUMPAD0 + 1, 0);
|
||||
c.message(WM_KEYUP, ALT, 0);
|
||||
c.message(WM_CHAR, 0xD83D, 0);
|
||||
c.message(WM_CHAR, 0xDE00, 0);
|
||||
let mut events = vec![];
|
||||
c.drain(&mut events);
|
||||
assert_eq!(events, vec![Event::Text("😀".into())]);
|
||||
}
|
||||
|
||||
/// Alt held, keypad digits `code` with NumLock off (keypad Insert, End, …, flags without the
|
||||
/// extended-key bit), Alt released, and the character Windows then posts.
|
||||
fn alt_code_numlock_off(c: &mut AltCodes, code: &str, posted: u16) {
|
||||
c.message(WM_SYSKEYDOWN, ALT, 0);
|
||||
for d in code.bytes() {
|
||||
let vk = NUMLOCK_OFF_KEYS[usize::from(d - b'0')];
|
||||
c.message(WM_SYSKEYDOWN, vk, 0);
|
||||
c.message(WM_SYSKEYUP, vk, 0);
|
||||
}
|
||||
c.message(WM_KEYUP, ALT, 0);
|
||||
c.message(WM_CHAR, usize::from(posted), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_numlock_off_the_keypad_keys_type_the_code_and_leave_the_caret_alone() {
|
||||
let mut c = AltCodes::default();
|
||||
alt_code_numlock_off(&mut c, "0176", 0xB0);
|
||||
// What egui reports for keypad 0, 1, 7 and 6 with NumLock off and Alt held, a press and a
|
||||
// release each, then a plain Home typed after Alt came up.
|
||||
let mut events = vec![];
|
||||
for k in [Key::Insert, Key::End, Key::Home, Key::ArrowRight] {
|
||||
events.push(key(k, true, true));
|
||||
events.push(key(k, true, false));
|
||||
}
|
||||
events.push(key(Key::Home, false, true));
|
||||
c.drain(&mut events);
|
||||
assert_eq!(events, vec![key(Key::Home, false, true), Event::Text("°".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_numlock_off_keypad_five_counts_too() {
|
||||
let mut c = AltCodes::default();
|
||||
// Keypad 5 is Clear, which egui reports as the top-row 5. 0151 uses Insert, End and Clear;
|
||||
// 0234 uses Insert, Down, Page Down and Left.
|
||||
alt_code_numlock_off(&mut c, "0151", 0x2014);
|
||||
alt_code_numlock_off(&mut c, "0234", 0xEA);
|
||||
let mut events =
|
||||
vec![key(Key::Num5, true, true), key(Key::ArrowDown, true, true), key(Key::PageDown, true, true), key(Key::ArrowLeft, true, true)];
|
||||
c.drain(&mut events);
|
||||
assert_eq!(events, vec![Event::Text("—ê".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_separate_navigation_keys_still_move_the_caret_and_cancel_the_code() {
|
||||
let mut c = AltCodes::default();
|
||||
c.message(WM_SYSKEYDOWN, ALT, 0);
|
||||
c.message(WM_SYSKEYDOWN, NUMLOCK_OFF_KEYS[1], 0);
|
||||
c.message(WM_SYSKEYUP, NUMLOCK_OFF_KEYS[1], 0);
|
||||
// Alt+Home on the separate Home key (extended): a shortcut, not a digit.
|
||||
c.message(WM_SYSKEYDOWN, NUMLOCK_OFF_KEYS[7], EXTENDED_KEY);
|
||||
c.message(WM_SYSKEYUP, NUMLOCK_OFF_KEYS[7], EXTENDED_KEY);
|
||||
c.message(WM_KEYUP, ALT, 0);
|
||||
c.message(WM_CHAR, 0x41, 0);
|
||||
let mut events = vec![key(Key::End, true, true), key(Key::End, true, false), key(Key::Home, true, true), key(Key::Home, true, false)];
|
||||
c.drain(&mut events);
|
||||
assert_eq!(events, vec![key(Key::Home, true, true), key(Key::Home, true, false)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numlock_off_keys_without_alt_are_left_alone() {
|
||||
let mut c = AltCodes::default();
|
||||
c.message(WM_KEYDOWN, NUMLOCK_OFF_KEYS[7], 0);
|
||||
c.message(WM_KEYUP, NUMLOCK_OFF_KEYS[7], 0);
|
||||
let mut events = vec![key(Key::Home, false, true), key(Key::Home, false, false)];
|
||||
c.drain(&mut events);
|
||||
assert_eq!(events.len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
//! fill them ([`gradient_slices`]); Outline Stroke turns the strip into a gradient mesh
|
||||
//! ([`gradient_meshes`]).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use kurbo::{BezPath, Point, Shape, Vec2};
|
||||
use vectorcraft_doc::color::{Gradient, GradientGeom, GradientPaint, Paint};
|
||||
use vectorcraft_doc::live::{H_DOWN, H_LEFT, H_RIGHT, H_UP};
|
||||
@@ -78,14 +80,26 @@ struct Lay<'a> {
|
||||
st: &'a StrokeLayer,
|
||||
/// Half the stroked width ([`aligned_width`]).
|
||||
hw: f64,
|
||||
/// How far ribs reach either side of the path where nothing wider is near.
|
||||
cover: f64,
|
||||
/// How far ribs reach past the stroke's edge.
|
||||
margin: f64,
|
||||
/// How far a strip runs on past the start and end of an open subpath (caps and heads).
|
||||
ext: (f64, f64),
|
||||
}
|
||||
|
||||
impl Lay<'_> {
|
||||
/// How far the stroke reaches either side of the path at fraction `t` of a subpath: its wider
|
||||
/// side, just before or just after a step there.
|
||||
fn half(&self, t: f64) -> f64 {
|
||||
let Some(p) = &self.st.profile else { return self.hw };
|
||||
let ((bl, br), (al, ar)) = p.around(t);
|
||||
self.hw * bl.max(br).max(al).max(ar).max(0.0)
|
||||
}
|
||||
|
||||
/// How far a rib at fraction `t` reaches either side of the path to cover the stroke there.
|
||||
fn cover(&self, t: f64) -> f64 {
|
||||
self.half(t) + self.margin
|
||||
}
|
||||
|
||||
/// The across band at fraction `t` of a subpath whose visible side is `side` (`Some(true)`:
|
||||
/// left; `None`: both, a centred stroke).
|
||||
fn band(&self, t: f64, side: Option<bool>) -> (f64, f64) {
|
||||
@@ -147,10 +161,11 @@ fn strips(bp: &BezPath, rule: FillRule, st: &StrokeLayer, tol: f64, margin: f64,
|
||||
if !(hw > 0.0 && hw.is_finite()) {
|
||||
return vec![];
|
||||
}
|
||||
let full = hw * st.profile_max();
|
||||
let heads = (st.head_reach(false), st.head_reach(true));
|
||||
let cap = cap_extent(st.cap, 2.0 * full);
|
||||
let lay = Lay { st, hw, cover: full + margin, margin, ext: (cap.max(heads.0) + margin, cap.max(heads.1) + margin) };
|
||||
let mut lay = Lay { st, hw, margin, ext: (0.0, 0.0) };
|
||||
// Caps reach as far past the ends as the stroke is wide there.
|
||||
let cap = |t: f64| cap_extent(st.cap, 2.0 * lay.half(t));
|
||||
lay.ext = (cap(0.0).max(heads.0) + margin, cap(1.0).max(heads.1) + margin);
|
||||
let subs = subpaths(bp);
|
||||
let count = subs.len();
|
||||
let mut out = vec![];
|
||||
@@ -165,7 +180,8 @@ fn strips(bp: &BezPath, rule: FillRule, st: &StrokeLayer, tol: f64, margin: f64,
|
||||
// A subpath of no length: a dot (round and projecting caps paint it).
|
||||
1 => {
|
||||
let (d, b, e) = (Vec2::new(1.0, 0.0), lay.band(0.0, side), lay.ext.0.max(lay.ext.1));
|
||||
vec![Rib::square(pts[0] - d * e, d, lay.cover, 0.0, b), Rib::square(pts[0] + d * e, d, lay.cover, 0.0, b)]
|
||||
let r = lay.cover(0.0);
|
||||
vec![Rib::square(pts[0] - d * e, d, r, 0.0, b), Rib::square(pts[0] + d * e, d, r, 0.0, b)]
|
||||
}
|
||||
_ => {
|
||||
let total = length(&pts, closed);
|
||||
@@ -215,9 +231,37 @@ fn strip(lay: &Lay, pts: &[Point], closed: bool, total: f64, side: Option<bool>,
|
||||
let n = pts.len();
|
||||
let seg_count = if closed { n } else { n - 1 };
|
||||
let dir: Vec<Vec2> = (0..seg_count).map(|i| unit(pts[(i + 1) % n] - pts[i])).collect();
|
||||
// Ribs near an arrowhead reach across it.
|
||||
let reach = |s: f64| {
|
||||
let mut r = lay.cover;
|
||||
// How far each point is from the next, and along the subpath.
|
||||
let seg: Vec<f64> = (0..n).map(|i| pts[i].distance(pts[(i + 1) % n])).collect();
|
||||
let along: Vec<f64> = seg
|
||||
.iter()
|
||||
.scan(0.0, |s, l| {
|
||||
let at = *s;
|
||||
*s += l;
|
||||
Some(at)
|
||||
})
|
||||
.collect();
|
||||
// How far the ribs at point `i` reach: past the stroke there and on its way to the points
|
||||
// either side, where it is no wider than at one of the two (each side of a width profile
|
||||
// stays between its widths at the points of a span, and its points have points here). The
|
||||
// caps and dots of a dashed stroke reach along the path as far as it is wide where they sit,
|
||||
// so there the ribs cover the widest the stroke gets that far either way. Not as far as the
|
||||
// stroke is wide anywhere: where a stroke crosses or runs close by itself, a narrow part
|
||||
// reaching that far would paint over a wide part beside it (#5960). The start of a closed
|
||||
// subpath is also its end. Ribs near an arrowhead reach across it.
|
||||
let cover: Vec<f64> = (0..n).map(|i| if closed && i == 0 { lay.cover(0.0).max(lay.cover(1.0)) } else { lay.cover(along[i] / total) }).collect();
|
||||
let window = match lay.st.dash {
|
||||
Some(_) if lay.st.profile.is_some() => cap_extent(lay.st.cap, 2.0 * lay.hw * lay.st.profile_max()),
|
||||
_ => 0.0,
|
||||
};
|
||||
let ahead = widest_ahead(&cover, &seg, closed, window);
|
||||
// Behind a point is ahead of it on the subpath run backwards.
|
||||
let back_seg: Vec<f64> = (0..n).map(|i| seg[(2 * n - 2 - i) % n]).collect();
|
||||
let back_cover: Vec<f64> = cover.iter().rev().copied().collect();
|
||||
let behind: Vec<f64> = widest_ahead(&back_cover, &back_seg, closed, window).into_iter().rev().collect();
|
||||
let reach = |i: usize| {
|
||||
let mut r = cover[i].max(ahead[i]).max(behind[i]);
|
||||
let s = along[i];
|
||||
if heads.0 > 0.0 && s <= heads.0 {
|
||||
r = r.max(heads.0 + lay.margin);
|
||||
}
|
||||
@@ -227,29 +271,69 @@ fn strip(lay: &Lay, pts: &[Point], closed: bool, total: f64, side: Option<bool>,
|
||||
r
|
||||
};
|
||||
let mut out = Vec::with_capacity(n + 4);
|
||||
let mut s = 0.0;
|
||||
if !closed {
|
||||
let (d, b) = (dir[0], lay.band(0.0, side));
|
||||
out.push(Rib::square(pts[0] - d * lay.ext.0, d, reach(0.0), 0.0, b));
|
||||
out.push(Rib::square(pts[0], d, reach(0.0), 0.0, b));
|
||||
s = pts[0].distance(pts[1]);
|
||||
out.push(Rib::square(pts[0] - d * lay.ext.0, d, reach(0), 0.0, b));
|
||||
out.push(Rib::square(pts[0], d, reach(0), 0.0, b));
|
||||
}
|
||||
let (first, last) = if closed { (0, n) } else { (1, n - 1) };
|
||||
for i in first..last {
|
||||
let v = i % n;
|
||||
let t = s / total;
|
||||
let ribs = lay.turn(pts[v], dir[(v + seg_count - 1) % seg_count], dir[v % seg_count], reach(s), t, lay.band(t, side));
|
||||
let t = along[v] / total;
|
||||
let ribs = lay.turn(pts[v], dir[(v + seg_count - 1) % seg_count], dir[v % seg_count], reach(v), t, lay.band(t, side));
|
||||
// A closed subpath starts after the fan of its first corner and ends with it.
|
||||
let skip = if closed && i == 0 { ribs.len() - 1 } else { 0 };
|
||||
out.extend(ribs.into_iter().skip(skip));
|
||||
s += pts[v].distance(pts[(v + 1) % n]);
|
||||
}
|
||||
if closed {
|
||||
out.extend(lay.turn(pts[0], dir[seg_count - 1], dir[0], reach(total), 1.0, lay.band(1.0, side)));
|
||||
out.extend(lay.turn(pts[0], dir[seg_count - 1], dir[0], reach(0), 1.0, lay.band(1.0, side)));
|
||||
} else {
|
||||
let (d, b) = (dir[seg_count - 1], lay.band(1.0, side));
|
||||
out.push(Rib::square(pts[n - 1], d, reach(total), 1.0, b));
|
||||
out.push(Rib::square(pts[n - 1] + d * lay.ext.1, d, reach(total), 1.0, b));
|
||||
out.push(Rib::square(pts[n - 1], d, reach(n - 1), 1.0, b));
|
||||
out.push(Rib::square(pts[n - 1] + d * lay.ext.1, d, reach(n - 1), 1.0, b));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// For each of the points of a subpath, how far the ribs of the points ahead of it reach
|
||||
/// (`cover`), out to the first point more than `window` along (or the end of an open subpath,
|
||||
/// or once round a closed one): `seg[i]` is how far point `i` is from the next (past the last,
|
||||
/// the first when `closed`). Both ends of the window only move forward, so a queue of the
|
||||
/// points that reach farther than every point after them gives each maximum, and a subpath
|
||||
/// costs time in proportion to its points however many a window spans.
|
||||
pub(super) fn widest_ahead(cover: &[f64], seg: &[f64], closed: bool, window: f64) -> Vec<f64> {
|
||||
let n = cover.len().min(seg.len());
|
||||
if n == 0 {
|
||||
return vec![];
|
||||
}
|
||||
// A closed subpath's points ahead are numbered on round it once more.
|
||||
let last = if closed { 2 * n - 1 } else { n - 1 };
|
||||
let mut pos = Vec::with_capacity(last + 1);
|
||||
let mut s = 0.0;
|
||||
for k in 0..=last {
|
||||
pos.push(s);
|
||||
s += seg[k % n];
|
||||
}
|
||||
let mut out = Vec::with_capacity(n);
|
||||
let mut queue: VecDeque<usize> = VecDeque::new();
|
||||
let mut next = 1;
|
||||
for i in 0..n {
|
||||
next = next.max(i + 1);
|
||||
let stop = if closed { i + n } else { last };
|
||||
let mut past = next > i + 1 && pos[next - 1] - pos[i] > window;
|
||||
while !past && next <= stop {
|
||||
let r = cover[next % n];
|
||||
while queue.back().is_some_and(|&k| cover[k % n] <= r) {
|
||||
queue.pop_back();
|
||||
}
|
||||
queue.push_back(next);
|
||||
past = pos[next] - pos[i] > window;
|
||||
next += 1;
|
||||
}
|
||||
while queue.front().is_some_and(|&k| k <= i) {
|
||||
queue.pop_front();
|
||||
}
|
||||
out.push(queue.front().map_or(0.0, |&k| cover[k % n]));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -237,3 +237,113 @@ fn slices_cover_an_arrowhead_on_a_long_straight_leg() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Does the last slice holding `p` (the one filled last there) lay its gradient piece on a part of
|
||||
/// the path running across the page (left or right), for a gradient in `mode`? Along, the piece
|
||||
/// runs the way the path does; across, square to it.
|
||||
fn painted_by_level_part(slices: &[Slice], p: Point, mode: StrokeGradientMode) -> Option<bool> {
|
||||
slices.iter().rev().find(|s| s.shape.contains(p)).map(|s| {
|
||||
let d = s.to - s.from;
|
||||
(d.x.abs() > d.y.abs()) == (mode == StrokeGradientMode::Along)
|
||||
})
|
||||
}
|
||||
|
||||
/// #5960: where a variable-width stroke crosses itself, the later pass paints over the earlier one
|
||||
/// only where the two overlap. Its slices reach as far as the stroke is wide there, not as far as
|
||||
/// it is wide anywhere, so a narrow later pass leaves the wide earlier one its own colour beside it.
|
||||
#[test]
|
||||
fn a_narrow_pass_over_a_wide_one_paints_only_where_they_overlap() {
|
||||
// Right along y = 50, round, then down through the first leg at x = 50, near the narrow end.
|
||||
let crossing = poly(&[(0.0, 50.0), (100.0, 50.0), (100.0, 100.0), (50.0, 100.0), (50.0, 0.0)], false);
|
||||
for mode in [StrokeGradientMode::Along, StrokeGradientMode::Across] {
|
||||
let mut st = stroke(10.0, mode);
|
||||
st.profile = Some(WidthProfile::taper_end());
|
||||
let sl = slices(&crossing, &st);
|
||||
// In the first leg (about 4 pt either side of it there) but 4 pt beside the second leg,
|
||||
// which is less than 1 pt either side of itself there.
|
||||
let level = painted_by_level_part(&sl, Point::new(54.0, 52.0), mode);
|
||||
assert_eq!(level, Some(true), "{mode:?}: painted by the second leg");
|
||||
// Where the two overlap, the second leg is on top.
|
||||
let level = painted_by_level_part(&sl, Point::new(50.5, 52.0), mode);
|
||||
assert_eq!(level, Some(false), "{mode:?}: painted by the first leg");
|
||||
assert_covered(&crossing, &st);
|
||||
}
|
||||
}
|
||||
|
||||
/// #5960: a narrow pass running close beside a wide one doesn't paint the wide one's edge.
|
||||
#[test]
|
||||
fn a_narrow_pass_beside_a_wide_one_leaves_its_edge_alone() {
|
||||
// Right along y = 0, then back along y = 7: the way back is the narrow end of a taper.
|
||||
let hairpin = poly(&[(0.0, 0.0), (100.0, 0.0), (100.0, 7.0), (0.0, 7.0)], false);
|
||||
let total = 207.0;
|
||||
let mut st = stroke(10.0, StrokeGradientMode::Along);
|
||||
st.profile = Some(WidthProfile::taper_end());
|
||||
let sl = slices(&hairpin, &st);
|
||||
// Half way along the first leg, 3 pt off it (it is about 3.8 pt wide there either side); the
|
||||
// way back is about 1.2 pt wide either side there, so it reaches no nearer than 5.8.
|
||||
let p = Point::new(50.0, 3.0);
|
||||
let t = param(&sl, p).expect("in a slice");
|
||||
assert!((t - 50.0 / total).abs() < 1e-6, "painted with {t}, not the first leg's {}", 50.0 / total);
|
||||
assert_covered(&hairpin, &st);
|
||||
}
|
||||
|
||||
/// #5960: with dashes, the ribs reach as far as the stroke gets wide within a cap's length along
|
||||
/// the path, however many points lie that far: not as far as it is wide anywhere when more than
|
||||
/// 256 do.
|
||||
#[test]
|
||||
fn a_densely_flattened_dashed_pass_beside_a_wide_one_leaves_its_edge_alone() {
|
||||
// The hairpin above, the way back in 0.01 pt steps: 500 points within a round cap's 5 pt.
|
||||
let mut pts = vec![(0.0, 0.0), (100.0, 0.0)];
|
||||
pts.extend((0..=10_000).map(|i| (100.0 - i as f64 * 0.01, 7.0)));
|
||||
let hairpin = poly(&pts, false);
|
||||
let total = 207.0;
|
||||
let mut st = stroke(10.0, StrokeGradientMode::Along);
|
||||
st.profile = Some(WidthProfile::taper_end());
|
||||
st.cap = LineCap::Round;
|
||||
st.dash = Some(Dash { pattern: vec![12.0, 6.0], ..Default::default() });
|
||||
let sl = slices(&hairpin, &st);
|
||||
let p = Point::new(50.0, 3.0);
|
||||
let t = param(&sl, p).expect("in a slice");
|
||||
assert!((t - 50.0 / total).abs() < 1e-6, "painted with {t}, not the first leg's {}", 50.0 / total);
|
||||
}
|
||||
|
||||
/// The widest the ribs ahead reach is the widest over the points out to the first one more than
|
||||
/// the window along, as a walk along the subpath finds it, open and closed.
|
||||
#[test]
|
||||
fn widest_ahead_is_the_widest_within_the_window() {
|
||||
let mut seed = 0x9e37_79b9_u64;
|
||||
let mut rnd = move || {
|
||||
seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407);
|
||||
(seed >> 11) as f64 / (1u64 << 53) as f64
|
||||
};
|
||||
for _ in 0..200 {
|
||||
let n = 1 + (rnd() * 40.0) as usize;
|
||||
let cover: Vec<f64> = (0..n).map(|_| rnd() * 10.0).collect();
|
||||
// Some points on top of each other.
|
||||
let seg: Vec<f64> = (0..n).map(|_| if rnd() < 0.2 { 0.0 } else { rnd() * 3.0 }).collect();
|
||||
let window = [0.0, rnd() * 5.0, rnd() * 200.0][(rnd() * 3.0) as usize];
|
||||
for closed in [false, true] {
|
||||
let got = super::gradient::widest_ahead(&cover, &seg, closed, window);
|
||||
assert_eq!(got.len(), n);
|
||||
for (i, &got) in got.iter().enumerate() {
|
||||
let (mut j, mut d, mut want) = (i, 0.0, 0.0_f64);
|
||||
for _ in 0..n {
|
||||
let k = if closed {
|
||||
(j + 1) % n
|
||||
} else if j + 1 < n {
|
||||
j + 1
|
||||
} else {
|
||||
break;
|
||||
};
|
||||
want = want.max(cover[k]);
|
||||
d += seg[j];
|
||||
j = k;
|
||||
if d > window {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!(got, want, "n {n}, closed {closed}, window {window}, point {i}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -930,7 +930,10 @@ fn without_inner(doc: &Document, placed: Vec<(NodeId, Option<Vec<usize>>)>) -> i
|
||||
/// The direct-selection flag and resolved root nodes for Copy and Cut operations.
|
||||
fn copy_cut_targets(s: &Session) -> Result<(bool, Vec<NodeId>)> {
|
||||
let st = s.doc()?;
|
||||
let in_drawing_tool = matches!(s.tool_id(), "pen" | "curvature");
|
||||
// The Pen and the Curvature tool leave the anchor they placed or edited selected: Copy takes
|
||||
// the whole path then (#2122), unless a selection tool Cmd lent them picked the anchors (#6123).
|
||||
let picked = s.lent_selection.as_ref().is_some_and(|(uid, sel)| *uid == st.uid && *sel == st.selection);
|
||||
let in_drawing_tool = matches!(s.tool_id(), "pen" | "curvature") && !picked;
|
||||
let direct = !in_drawing_tool && (!st.selection.anchors.is_empty() || !st.selection.segments.is_empty());
|
||||
let in_isolated_compound = st.isolation.is_some_and(|iso| st.doc.node(iso).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. })));
|
||||
let roots = roots_within(&st.doc, st.selection.in_paint_order(&st.doc), |_compound, member| {
|
||||
|
||||
@@ -129,7 +129,9 @@ pub(crate) use access::{drive_share_path, share_path, touched};
|
||||
pub(crate) use access::{may_read, may_write};
|
||||
pub use commands::specs;
|
||||
pub use export_names::artboard_file_names;
|
||||
pub(crate) use export_names::{default_name, merge, unique_file_names, with_black_output, write_or_return};
|
||||
pub(crate) use export_names::{
|
||||
artboard_file_names_keeping_dots, default_name, merge, unique_file_names_keeping_dots, with_black_output, write_or_return,
|
||||
};
|
||||
use export_names::{distinct_names_by, write_encoded};
|
||||
use formats::{ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, import_name, writable};
|
||||
pub use formats::{
|
||||
|
||||
@@ -5,19 +5,46 @@ use super::{Encoded, EngineError, Result, Session, Value, json};
|
||||
/// `-`, `Artboard-N` when unnamed, and `-2`, `-3`… after a name already used (compared without
|
||||
/// case: `Icon` and `icon` are one file on most desktop file systems).
|
||||
pub fn artboard_file_names(doc: &vectorcraft_doc::Document, boards: &[usize]) -> Vec<String> {
|
||||
unique_file_names(boards.iter().map(|&b| (doc.artboards.get(b).map_or("", |a| a.name.as_str()), format!("Artboard-{}", b + 1))))
|
||||
unique_file_names(artboard_names(doc, boards))
|
||||
}
|
||||
|
||||
/// [`artboard_file_names`] with the dots of a name kept (`example.artboard`), for Export for Screens,
|
||||
/// which puts every stem through `safe_file_name` after this (trailing periods, device names).
|
||||
pub(crate) fn artboard_file_names_keeping_dots(doc: &vectorcraft_doc::Document, boards: &[usize]) -> Vec<String> {
|
||||
unique_file_names_keeping_dots(artboard_names(doc, boards))
|
||||
}
|
||||
|
||||
/// Each of `boards`' name, with the file name to use when it has none.
|
||||
fn artboard_names<'a>(doc: &'a vectorcraft_doc::Document, boards: &'a [usize]) -> impl Iterator<Item = (&'a str, String)> {
|
||||
boards.iter().map(|&b| (doc.artboards.get(b).map_or("", |a| a.name.as_str()), format!("Artboard-{}", b + 1)))
|
||||
}
|
||||
|
||||
/// File-name parts for `(name, fallback)` pairs (artboards, assets): the name with unsafe
|
||||
/// characters as `-`, the fallback when it is empty, and `-2`, `-3`… after a name already used
|
||||
/// (compared without case).
|
||||
pub(crate) fn unique_file_names<'a>(names: impl IntoIterator<Item = (&'a str, String)>) -> Vec<String> {
|
||||
fn unique_file_names<'a>(names: impl IntoIterator<Item = (&'a str, String)>) -> Vec<String> {
|
||||
distinct_names(names.into_iter().map(|(name, fallback)| {
|
||||
let base: String = name.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '-' }).collect();
|
||||
if base.is_empty() { fallback } else { base }
|
||||
}))
|
||||
}
|
||||
|
||||
/// [`unique_file_names`] keeping `.` in a name (an artboard called `example.artboard` exports as
|
||||
/// `example.artboard.png`, #6143). A name of only dots (`.` and `..` name other folders) takes the
|
||||
/// fallback, and leading dots are dashes (`.draft` would be a hidden file); the caller still runs
|
||||
/// each stem through `safe_file_name`, which drops trailing periods and guards Windows device names.
|
||||
pub(crate) fn unique_file_names_keeping_dots<'a>(names: impl IntoIterator<Item = (&'a str, String)>) -> Vec<String> {
|
||||
distinct_names(names.into_iter().map(|(name, fallback)| {
|
||||
let base: String = name.chars().map(|c| if c.is_alphanumeric() || matches!(c, '-' | '_' | '.') { c } else { '-' }).collect();
|
||||
if base.trim_matches('.').is_empty() {
|
||||
return fallback;
|
||||
}
|
||||
// A leading dot would hide the file or folder on Unix-like systems: it is a dash, as before.
|
||||
let rest = base.trim_start_matches('.');
|
||||
format!("{}{rest}", "-".repeat(base.len() - rest.len()))
|
||||
}))
|
||||
}
|
||||
|
||||
/// `names` with `-2`, `-3`… after a name already used (compared without case).
|
||||
pub(super) fn distinct_names(names: impl IntoIterator<Item = String>) -> Vec<String> {
|
||||
distinct_names_by(names, str::to_lowercase)
|
||||
|
||||
@@ -21,8 +21,8 @@ use super::encode::single_artboard;
|
||||
use super::export::{export_crop, isolated, selected_artwork, without_artboards};
|
||||
use super::naming::{self, NameParts};
|
||||
use super::{
|
||||
ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names, create_dir, distinct_names_by, encode_with_warnings, merge, unique_file_names,
|
||||
with_black_output, writable, write_file,
|
||||
ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names_keeping_dots, create_dir, distinct_names_by, encode_with_warnings, merge,
|
||||
unique_file_names_keeping_dots, with_black_output, writable, write_file,
|
||||
};
|
||||
|
||||
const C: &str = "document.exportForScreens";
|
||||
@@ -1184,7 +1184,7 @@ fn items(
|
||||
// the item folders are (`fold`).
|
||||
let safe = |stems: Vec<String>| distinct_names_by(stems.iter().map(|s| safe_file_name(s)), fold);
|
||||
let named = |pieces: Vec<(String, Arc<Document>, Option<usize>, Rect)>, fallback: &str| -> Vec<Item> {
|
||||
let stems = safe(unique_file_names(pieces.iter().map(|(n, ..)| (n.as_str(), fallback.to_string()))));
|
||||
let stems = safe(unique_file_names_keeping_dots(pieces.iter().map(|(n, ..)| (n.as_str(), fallback.to_string()))));
|
||||
pieces
|
||||
.into_iter()
|
||||
.zip(stems)
|
||||
@@ -1261,7 +1261,7 @@ fn items(
|
||||
if boards.is_empty() {
|
||||
return Err(bad(C, "the document has no artboard"));
|
||||
}
|
||||
let stems = safe(artboard_file_names(doc, &boards));
|
||||
let stems = safe(artboard_file_names_keeping_dots(doc, &boards));
|
||||
Ok(boards
|
||||
.into_iter()
|
||||
.zip(stems)
|
||||
|
||||
@@ -148,6 +148,19 @@ fn device_names_and_trailing_periods_follow_windows_naming_rules() {
|
||||
assert_eq!(r["name"], "nul-.zip");
|
||||
}
|
||||
|
||||
/// A dot in an artboard's name stays a dot (`example.artboard.png`, #6143); a name of only dots, or
|
||||
/// ending in one, still can't name another folder or end in a period, a leading dot is a dash (no
|
||||
/// hidden `.draft.png`), and `/` and `:` are dashes.
|
||||
#[test]
|
||||
fn an_artboard_name_keeps_its_dots() {
|
||||
let mut s = session(7);
|
||||
for (index, name) in ["example.artboard", "v1.2.final", "..", "a/b:c", "trail.", ".draft", "..x.y"].iter().enumerate() {
|
||||
s.execute("artboard.setProps", &json!({"index": index, "name": name})).unwrap();
|
||||
}
|
||||
let r = export(&mut s, json!({"formats": [{"format": "png"}]}));
|
||||
assert_eq!(names(&r), ["example.artboard.png", "v1.2.final.png", "Artboard-3.png", "a-b-c.png", "trail.png", "-draft.png", "--x.y.png"]);
|
||||
}
|
||||
|
||||
/// `first_file` names the file the export writes first, for the same params: the item's file name,
|
||||
/// a prefix and a suffix with characters Windows does not allow, a range, a Windows device name,
|
||||
/// every kind of folder, presets, size suffixes, a name pattern with its counter and date, an
|
||||
|
||||
@@ -10,8 +10,9 @@ use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind, TextKind, TextObject, TextRun};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
use super::select::on_artboard;
|
||||
use super::typecmd::refresh_bounds;
|
||||
use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, str_param};
|
||||
use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, index, str_param};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -38,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Select Text by Font",
|
||||
[],
|
||||
None,
|
||||
"{family, style?} select the text objects using the font, and the instances of the symbols whose art uses it, that are not hidden or locked, in a hidden or locked group or layer, or on a locked artboard → {count}",
|
||||
"{family, style?, scope?: \"document\" (default) | {artboards: [index, …] (0-based)} | {layers: [id, …] (layers or sublayers)}} select the text objects using the font, and the instances of the symbols whose art uses it, that are not hidden or locked, in a hidden or locked group or layer, or on a locked artboard; with scope, only those on one of the artboards (as All on Active Artboard takes them: their bounds overlap it or their center is on it) or in one of the layers → {count}",
|
||||
has_doc,
|
||||
select_font
|
||||
),
|
||||
@@ -713,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();
|
||||
@@ -758,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") {
|
||||
@@ -911,6 +967,95 @@ mod tests {
|
||||
assert!(s.doc().unwrap().selection.objects.is_empty());
|
||||
}
|
||||
|
||||
/// Select Text by Font with an artboard scope takes the text on the artboards, as All on
|
||||
/// Active Artboard does: overlapping one, or centered on it, a straddling one included.
|
||||
#[test]
|
||||
fn select_font_in_artboards() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
s.execute("artboard.new", &json!({"x": 300, "y": 0, "width": 200, "height": 200})).unwrap();
|
||||
s.execute("artboard.new", &json!({"x": 600, "y": 0, "width": 200, "height": 200})).unwrap();
|
||||
let text = |s: &mut Session, x: i32| {
|
||||
NodeId(s.execute("text.create", &json!({"x": x, "y": 50, "text": "Serif", "font": "Source Serif 4"})).unwrap()["id"].as_u64().unwrap())
|
||||
};
|
||||
let first = text(&mut s, 10);
|
||||
let straddling = text(&mut s, 180);
|
||||
let second = text(&mut s, 350);
|
||||
let third = text(&mut s, 650);
|
||||
let _off = text(&mut s, 1000);
|
||||
let select = |s: &mut Session, scope: Value| {
|
||||
s.execute("select.font", &json!({"family": "Source Serif 4", "scope": scope})).unwrap();
|
||||
s.doc().unwrap().selection.objects.clone()
|
||||
};
|
||||
assert_eq!(select(&mut s, json!({"artboards": [0]})), [first, straddling]);
|
||||
assert_eq!(select(&mut s, json!({"artboards": [1, 2]})), [second, third]);
|
||||
assert_eq!(select(&mut s, json!("document")).len(), 5);
|
||||
// The same objects All on Active Artboard takes.
|
||||
s.execute("select.allOnArtboard", &json!({"artboard": 0})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [first, straddling]);
|
||||
// Hidden text stays out.
|
||||
s.execute("select.set", &json!({"ids": [third.0]})).unwrap();
|
||||
s.execute("object.hide", &json!({})).unwrap();
|
||||
assert_eq!(select(&mut s, json!({"artboards": [1, 2]})), [second]);
|
||||
for bad in [json!({"artboards": [3]}), json!({"artboards": []}), json!({"artboards": ["x"]}), json!({"pages": [0]}), json!("all"), json!(7)] {
|
||||
assert!(s.execute("select.font", &json!({"family": "Source Serif 4", "scope": bad})).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Select Text by Font with a layer scope takes the text in the layers, their sublayers
|
||||
/// included, and still leaves out hidden and locked text.
|
||||
#[test]
|
||||
fn select_font_in_layers() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
let text = |s: &mut Session, y: i32| {
|
||||
NodeId(s.execute("text.create", &json!({"x": 10, "y": y, "text": "Serif", "font": "Source Serif 4"})).unwrap()["id"].as_u64().unwrap())
|
||||
};
|
||||
let first_layer = s.doc().unwrap().doc.layers[0].id;
|
||||
let a = text(&mut s, 20);
|
||||
let second_layer = s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
let b = text(&mut s, 40);
|
||||
let sub = s.execute("layer.newSublayer", &json!({"parent": second_layer})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("layer.setCurrent", &json!({"id": sub})).unwrap();
|
||||
let in_sub = text(&mut s, 60);
|
||||
let locked = text(&mut s, 80);
|
||||
s.execute("select.set", &json!({"ids": [locked.0]})).unwrap();
|
||||
s.execute("object.lock", &json!({})).unwrap();
|
||||
let select = |s: &mut Session, layers: Value| {
|
||||
s.execute("select.font", &json!({"family": "Source Serif 4", "scope": {"layers": layers}})).unwrap();
|
||||
let mut v = s.doc().unwrap().selection.objects.clone();
|
||||
v.sort();
|
||||
v
|
||||
};
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!(d.ancestry(in_sub).unwrap()[1], NodeId(sub), "the text is in the sublayer");
|
||||
assert_eq!(select(&mut s, json!([first_layer.0])), [a]);
|
||||
assert_eq!(select(&mut s, json!([second_layer])), [b, in_sub]);
|
||||
assert_eq!(select(&mut s, json!([sub])), [in_sub]);
|
||||
assert_eq!(select(&mut s, json!([first_layer.0, sub])), [a, in_sub]);
|
||||
// Not a layer: an error, the selection as it was.
|
||||
assert!(s.execute("select.font", &json!({"family": "Source Serif 4", "scope": {"layers": [a.0]}})).is_err());
|
||||
assert!(s.execute("select.font", &json!({"family": "Source Serif 4", "scope": {"layers": [u64::MAX]}})).is_err());
|
||||
assert_eq!(s.doc().unwrap().selection.objects.len(), 2);
|
||||
}
|
||||
|
||||
/// A symbol instance whose art uses the font is selected when the instance is in scope.
|
||||
#[test]
|
||||
fn select_font_scope_takes_symbol_instances_in_it() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
s.execute("artboard.new", &json!({"x": 300, "y": 0, "width": 200, "height": 200})).unwrap();
|
||||
let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Serif", "font": "Source Serif 4"})).unwrap()["id"].clone();
|
||||
s.execute("symbol.new", &json!({"name": "Mark", "ids": [id]})).unwrap();
|
||||
let instance = s.doc().unwrap().selection.objects[0];
|
||||
let select = |s: &mut Session, i: u64| {
|
||||
s.execute("select.font", &json!({"family": "Source Serif 4", "scope": {"artboards": [i]}})).unwrap()["count"].clone()
|
||||
};
|
||||
assert_eq!(select(&mut s, 0), 1);
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [instance]);
|
||||
assert_eq!(select(&mut s, 1), 0);
|
||||
}
|
||||
|
||||
/// Replace Font leaves hidden and locked text in its font, and text inside a hidden or locked
|
||||
/// group or layer, and says so. Text on a visible, unlocked template layer is changed.
|
||||
#[test]
|
||||
|
||||
@@ -96,7 +96,9 @@ pub(crate) fn import(d: &mut Document, lib: &Document, name: &str) -> String {
|
||||
pub(crate) fn existing_pattern(d: &Document, lib: &Document, name: &str) -> Option<String> {
|
||||
let def = lib.pattern(name)?;
|
||||
let bare = |p: &PatternDef| PatternDef { name: String::new(), art: vec![], ..p.clone() };
|
||||
let copy = |n: &str| n == name || n.strip_prefix(name).and_then(|r| r.strip_prefix(' ')).is_some_and(|r| r.parse::<u32>().is_ok());
|
||||
let copy = |n: &str| {
|
||||
n == name || n.strip_prefix(name).and_then(|r| r.strip_prefix(' ')).is_some_and(|r| !r.is_empty() && r.bytes().all(|b| b.is_ascii_digit()))
|
||||
};
|
||||
let mut found: Vec<&PatternDef> = d.patterns.iter().filter(|p| copy(&p.name) && bare(p) == bare(def) && p.art == def.art).collect();
|
||||
found.sort_by_key(|p| p.name != name);
|
||||
found.first().map(|p| p.name.clone())
|
||||
@@ -150,3 +152,19 @@ fn geometric_patterns(d: &mut Document) {
|
||||
add(d, "Crosses", 10.0, 10.0, TileType::Grid, vec![rect(4.0, 1.5, 6.0, 8.5), rect(1.5, 4.0, 8.5, 6.0)]);
|
||||
add(d, "Zigzag", 10.0, 8.0, TileType::Grid, vec![polygon(&[(0.0, 5.0), (5.0, 1.0), (10.0, 5.0), (10.0, 7.0), (5.0, 3.0), (0.0, 7.0)])]);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn plus_signed_suffix_is_not_a_copy_number() {
|
||||
let mut lib = Document::new(100.0, 100.0);
|
||||
lib.patterns.push(PatternDef::new("Dots", vec![]));
|
||||
for (other, expected) in [("Dots +2", None), ("Dots -2", None), ("Dots 2a", None), ("Dots ", None), ("Dots 2", Some("Dots 2"))] {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
d.patterns.push(PatternDef::new(other, vec![]));
|
||||
assert_eq!(existing_pattern(&d, &lib, "Dots").as_deref(), expected, "{other}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -611,6 +611,9 @@ pub(crate) fn alt_code_char(code: u64) -> Option<char> {
|
||||
fn unicode_char(u: &str) -> Option<char> {
|
||||
let u = u.trim();
|
||||
let hex = u.strip_prefix("U+").or_else(|| u.strip_prefix("u+")).or_else(|| u.strip_prefix("0x")).unwrap_or(u);
|
||||
if hex.is_empty() || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
char::from_u32(u32::from_str_radix(hex, 16).ok()?).filter(|c| !c.is_control() || *c == '\t')
|
||||
}
|
||||
|
||||
@@ -843,3 +846,18 @@ fn paste_plain(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::unicode_char;
|
||||
|
||||
#[test]
|
||||
fn unicode_char_rejects_sign_in_hex_digits() {
|
||||
assert_eq!(unicode_char("+B0"), None);
|
||||
assert_eq!(unicode_char("U++B0"), None);
|
||||
assert_eq!(unicode_char("U+"), None);
|
||||
assert_eq!(unicode_char("00B0"), Some('°'));
|
||||
assert_eq!(unicode_char("U+00B0"), Some('°'));
|
||||
assert_eq!(unicode_char("0x00B0"), Some('°'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,14 @@ pub struct Session {
|
||||
/// While Cmd lends `tool` (a selection tool) for a drag: the tool it was lent to, which comes
|
||||
/// back as it was at the release ([`Session::pointer`]).
|
||||
pub(crate) lender: Option<Box<dyn Tool>>,
|
||||
/// The selection of the active document (its uid) as Cmd lent `tool`: what the lent tool's
|
||||
/// gesture found, so that one that leaves it as it was picks nothing.
|
||||
pub(crate) selection_at_lend: Option<(u64, vectorcraft_doc::Selection)>,
|
||||
/// The selection a lent tool's gesture changed and left as it gave `lender` back, in the
|
||||
/// document (its uid) it was made in: while it is still that document's selection, the anchors
|
||||
/// in it are ones picked, not the Pen's last anchor, and Copy and Cut take them with the Pen or
|
||||
/// Curvature tool back too (#6123).
|
||||
pub(crate) lent_selection: Option<(u64, vectorcraft_doc::Selection)>,
|
||||
/// The selection tool chosen last (Selection, Direct Selection or Group Selection): the one Cmd
|
||||
/// lends the other tools. None until one is chosen.
|
||||
pub(crate) last_selection_tool: Option<&'static str>,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -2673,6 +2673,87 @@ fn pen_with_cmd_borrows_direct_selection_and_goes_on_drawing() {
|
||||
assert_eq!(s.tool_id(), "rectangle");
|
||||
}
|
||||
|
||||
/// #6123: anchors that Cmd (Ctrl) held with the Pen selects with the Direct Selection it lends are
|
||||
/// what Copy and Cut take, as with Direct Selection chosen; the last anchor the Pen leaves selected
|
||||
/// while drawing is not, Copy taking the whole path (#2122), nor after a Cmd gesture that picks
|
||||
/// nothing.
|
||||
#[test]
|
||||
fn pen_cmd_selected_anchors_are_what_copy_and_cut_take() {
|
||||
let (none, cmd) = (Mods::default(), Mods { cmd: true, ..Mods::default() });
|
||||
let v = view();
|
||||
let mut s = session();
|
||||
s.select_tool("pen", v).unwrap();
|
||||
for x in [100.0, 200.0, 300.0, 400.0] {
|
||||
gesture(&mut s, &[(x, 300.0)], none);
|
||||
}
|
||||
let id = s.doc().unwrap().selection.objects[0];
|
||||
let pasted = |s: &mut Session| {
|
||||
let ids = s.execute("edit.paste", &json!({})).unwrap()["ids"].clone();
|
||||
let ids: Vec<NodeId> = ids.as_array().unwrap().iter().map(|i| NodeId(i.as_u64().unwrap())).collect();
|
||||
ids.iter().map(|i| path(s, *i).subpaths[0].anchors.len()).collect::<Vec<_>>()
|
||||
};
|
||||
// A Cmd marquee around the middle two anchors: Copy takes them and the segment between.
|
||||
gesture(&mut s, &[(150.0, 250.0), (350.0, 350.0)], cmd);
|
||||
assert_eq!(s.tool_id(), "pen");
|
||||
assert_eq!(s.doc().unwrap().selection.anchors[&id], [(0, 1), (0, 2)].into());
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
assert_eq!(pasted(&mut s), [2]);
|
||||
// Cut takes the segment between them out of the path, as with Direct Selection chosen.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.cut", &json!({})).unwrap();
|
||||
let left = path(&s, id).subpaths.iter().map(|sp| sp.anchors.len()).collect::<Vec<_>>();
|
||||
assert_eq!((s.clipboard.nodes.len(), left), (1, vec![2, 2]));
|
||||
// Drawing on: the last anchor the Pen leaves selected doesn't make Copy take that anchor.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
gesture(&mut s, &[(500.0, 300.0)], none);
|
||||
assert_eq!(s.doc().unwrap().selection.anchors[&id], [(0, 4)].into());
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
assert_eq!(pasted(&mut s), [5]);
|
||||
// A Cmd+Shift marquee over nothing picks nothing: the Pen's last anchor stays its own.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let shift_cmd = Mods { shift: true, ..cmd };
|
||||
gesture(&mut s, &[(600.0, 500.0), (700.0, 600.0)], shift_cmd);
|
||||
assert_eq!(s.doc().unwrap().selection.anchors[&id], [(0, 4)].into());
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
assert_eq!(pasted(&mut s), [5]);
|
||||
// The tool started afresh in the same document (another view of it) keeps a pick picked.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
gesture(&mut s, &[(150.0, 250.0), (350.0, 350.0)], cmd);
|
||||
s.reset_tool();
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
assert_eq!(pasted(&mut s), [2]);
|
||||
}
|
||||
|
||||
/// #6123: with the other tools, Cmd (Ctrl) lends the selection tool chosen last. Direct Selection
|
||||
/// lent picks anchors, which Copy takes, as with the Curvature tool, which Cmd lends Direct
|
||||
/// Selection until a selection tool is chosen; the Selection tool lent selects the path whole.
|
||||
#[test]
|
||||
fn cmd_selected_anchors_with_other_tools_are_what_copy_takes() {
|
||||
let cmd = Mods { cmd: true, ..Mods::default() };
|
||||
let v = view();
|
||||
let copied = |tool: &str, last_selection: Option<&str>| {
|
||||
let mut s = session();
|
||||
let anchors = json!([{"x": 100, "y": 300}, {"x": 200, "y": 300}, {"x": 300, "y": 300}, {"x": 400, "y": 300}]);
|
||||
s.execute("path.create", &json!({"anchors": anchors})).unwrap();
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
if let Some(t) = last_selection {
|
||||
s.select_tool(t, v).unwrap();
|
||||
}
|
||||
s.select_tool(tool, v).unwrap();
|
||||
gesture(&mut s, &[(150.0, 250.0), (350.0, 350.0)], cmd);
|
||||
assert_eq!(s.tool_id(), tool);
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
let ids = s.execute("edit.paste", &json!({})).unwrap()["ids"].clone();
|
||||
let id = NodeId(ids[0].as_u64().unwrap());
|
||||
path(&s, id).subpaths[0].anchors.len()
|
||||
};
|
||||
assert_eq!(copied("rectangle", Some("directSelection")), 2);
|
||||
assert_eq!(copied("ellipse", Some("directSelection")), 2);
|
||||
assert_eq!(copied("curvature", None), 2);
|
||||
assert_eq!(copied("rectangle", None), 4, "the Selection tool lent selects the path whole");
|
||||
}
|
||||
|
||||
/// #4980: Cmd/Ctrl held with the Pen drags as Direct Selection: an anchor of the path being drawn
|
||||
/// moves on its own and the Pen goes on drawing that path from it; another path's straight segment
|
||||
/// moves both its ends and a curved one bends; each drag is one undo step and the pointer is the
|
||||
|
||||
@@ -219,6 +219,7 @@ impl Session {
|
||||
// The Selection and Group Selection tools act on the instance whole.
|
||||
self.select_whole_instance(id);
|
||||
let lent = self.make_tool(id);
|
||||
self.selection_at_lend = self.active().map(|d| (d.uid, d.selection.clone()));
|
||||
self.lender = Some(std::mem::replace(&mut self.tool, lent));
|
||||
PointerEvent { mods: Mods { cmd: false, ..ev.mods }, ..*ev }
|
||||
}
|
||||
@@ -232,6 +233,12 @@ impl Session {
|
||||
let r = self.apply_actions(acts);
|
||||
self.keep_tool_settings();
|
||||
self.tool = lender;
|
||||
// A gesture that left the selection as it found it (a Shift marquee over nothing) picked
|
||||
// nothing: the selection keeps what it was, the Pen's last anchor or an earlier pick.
|
||||
let left = self.active().map(|d| (d.uid, d.selection.clone()));
|
||||
if left != self.selection_at_lend.take() {
|
||||
self.lent_selection = left;
|
||||
}
|
||||
self.after_command(None);
|
||||
r.map(drop)
|
||||
}
|
||||
|
||||
@@ -230,6 +230,10 @@ fn structured_junk() {
|
||||
("document.export", json!({"format": "bmp", "rect": {"x": 1}, "artboard": 3})),
|
||||
("document.export", json!({"format": "svg", "rect": [0, 0, 10, 10]})),
|
||||
("select.add", json!({"ids": [[1], {"a": 1}]})),
|
||||
("select.font", json!({"family": "Source Sans 3", "scope": {"artboards": [0, u64::MAX, -1, "x", null, 1.5]}})),
|
||||
("select.font", json!({"family": "Source Sans 3", "scope": {"layers": [0, u64::MAX, -1, [1], {"a": 1}]}})),
|
||||
("select.font", json!({"family": "Source Sans 3", "scope": {"artboards": [], "layers": [1]}})),
|
||||
("select.font", json!({"family": "Source Sans 3", "scope": 7})),
|
||||
("select.anchors", json!({"id": 2, "anchors": [[99, 99], [-1, 0], ["a", "b"]], "mode": "set"})),
|
||||
("select.anchorsMany", json!({"items": [{"id": 2, "anchors": [[0, 1000]]}, null, 5]})),
|
||||
("select.anchorsMany", json!({"items": [{"id": 2, "anchors": [[0, 1000], [0, 0]]}, {"id": u64::MAX}], "mode": "toggle"})),
|
||||
|
||||
@@ -2381,6 +2381,53 @@ proptest! {
|
||||
);
|
||||
survive("TIFF-predicted EPS images", || vectorcraft_eps::import(text.as_bytes()).ok().map(|r| r.document))?;
|
||||
}
|
||||
|
||||
/// Damaged or hostile TrueType data in a Type 42 font that a composite font shows (its table
|
||||
/// directory and its glyphs), any glyph index: drawn, measured and outlined, or left out.
|
||||
#[test]
|
||||
fn eps_type42_glyphs_never_panic(
|
||||
head in prop::collection::vec((0usize..2048, any::<u8>()), 0..12),
|
||||
body in prop::collection::vec((0usize..TYPE42_FONT.len(), any::<u8>()), 0..12),
|
||||
gid in 0u32..70_000,
|
||||
) {
|
||||
let mut font = TYPE42_FONT.to_vec();
|
||||
for (at, b) in head.into_iter().chain(body) {
|
||||
if let Some(x) = font.get_mut(at) {
|
||||
*x = b;
|
||||
}
|
||||
}
|
||||
let text = type42_composite_eps(&font, gid);
|
||||
survive("damaged Type 42 glyphs", || vectorcraft_eps::import(text.as_bytes()).ok().map(|r| r.document))?;
|
||||
}
|
||||
}
|
||||
|
||||
/// The TrueType font [`type42_composite_eps`] carries.
|
||||
const TYPE42_FONT: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf");
|
||||
|
||||
/// An EPS showing glyph `gid` of TrueType font `font` through a Type 42 font and a composite font
|
||||
/// over it, and measuring and outlining it.
|
||||
fn type42_composite_eps(font: &[u8], gid: u32) -> String {
|
||||
let sfnts: String = font.chunks(32_000).map(|c| format!("<{}>\n", c.iter().map(|b| format!("{b:02x}")).collect::<String>())).collect();
|
||||
format!(
|
||||
"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n\
|
||||
10 dict begin /FontName /T42 def /FontType 42 def /FontMatrix [1 0 0 1 0 0] def /FontBBox [0 0 1 1] def /PaintType 0 def\n\
|
||||
/Encoding 256 array def 0 1 255 {{ Encoding exch /.notdef put }} for Encoding 1 /c01 put\n\
|
||||
/CharStrings 2 dict def CharStrings begin /.notdef 0 def /c01 {gid} def end /sfnts [\n{sfnts}] def FontName currentdict end definefont pop\n\
|
||||
16 dict begin /FontName /Comp def /FontType 0 def /FontMatrix [1 0 0 1 0 0] def /FMapType 2 def /Encoding [0] def\n\
|
||||
/FDepVector [/T42 findfont] def FontName currentdict end definefont pop\n\
|
||||
/Comp findfont 40 scalefont setfont 10 20 moveto (\\000\\001\\000\\001) show (\\000\\001) stringwidth pop pop\n\
|
||||
10 60 moveto (\\000\\001) true charpath fill\nshowpage\n%%EOF\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// [`type42_composite_eps`] as it is: the glyph draws as filled outlines, not as type.
|
||||
#[test]
|
||||
fn the_type42_fixture_draws_its_glyphs() {
|
||||
let r = vectorcraft_eps::import(type42_composite_eps(TYPE42_FONT, 9).as_bytes()).unwrap();
|
||||
assert!(r.warnings.is_empty() && !r.preview, "{:?}", r.warnings);
|
||||
let mut kinds = vec![];
|
||||
r.document.walk(|n| kinds.push(n.kind_label()));
|
||||
assert!(!kinds.contains(&"Text") && kinds.iter().filter(|k| **k == "Path").count() >= 3, "{kinds:?}");
|
||||
}
|
||||
|
||||
/// An EPS with two 8 × 3 CCITT fax images that the PostScript interpreter reads, coded by hand from
|
||||
|
||||
@@ -17,6 +17,8 @@ vectorcraft-effects = { workspace = true }
|
||||
vectorcraft-pathops = { workspace = true }
|
||||
vectorcraft-brush = { workspace = true }
|
||||
vectorcraft-text = { workspace = true }
|
||||
# The glyph outlines of Type 42 fonts a composite font shows.
|
||||
skrifa = { workspace = true }
|
||||
# Windows metafile previews, opened when a file's PostScript can't be read.
|
||||
vectorcraft-metafile = { workspace = true }
|
||||
# Non-native art in Illustrator editing data (a placed PDF's content, kept as a PDF).
|
||||
|
||||
@@ -44,10 +44,13 @@ mod obj;
|
||||
mod preview;
|
||||
mod shading;
|
||||
mod text;
|
||||
mod type42;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_composite;
|
||||
#[cfg(test)]
|
||||
mod tests_fontnames;
|
||||
#[cfg(test)]
|
||||
mod tests_generators;
|
||||
|
||||
@@ -67,20 +67,29 @@ pub(super) fn color_op(op: &str, vals: &[V]) -> Option<(Paint, Option<Named>)> {
|
||||
"x" => named(vals, Some(false)),
|
||||
"xx" => named(vals, None),
|
||||
"xk" => named(vals, None).map(|(p, n)| (p, n.map(|n| Named { spot: false, ..n }))),
|
||||
"xs" | "xz" => registration(vals),
|
||||
"xs" | "xz" => registration(op, vals),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_registration(name: &str) -> bool {
|
||||
matches!(name, vectorcraft_color::swatch::REGISTRATION | "Registration" | "/All" | "All" | "/[Registration]")
|
||||
}
|
||||
|
||||
/// `c m y k (name) tint x` or `c m y k [r g b | L a b] (name) tint type Xx` (type 1: RGB, 2: Lab);
|
||||
/// `rgb` forces RGB or CMYK.
|
||||
fn named(vals: &[V], rgb: Option<bool>) -> Option<(Paint, Option<Named>)> {
|
||||
let at = vals.iter().rposition(|v| matches!(v, V::Str(_)))?;
|
||||
let at = vals.iter().rposition(|v| matches!(v, V::Str(_) | V::Name(_)))?;
|
||||
let name = vals.get(at)?.text()?;
|
||||
let before: Vec<f32> = vals.get(..at)?.iter().filter_map(V::num).map(|v| v as f32).collect();
|
||||
let after: Vec<f32> = vals.get(at + 1..)?.iter().filter_map(V::num).map(|v| v as f32).collect();
|
||||
let unit = |v: f32| v.clamp(0.0, 1.0);
|
||||
let tint = 1.0 - unit(*after.first()?);
|
||||
if is_registration(&name) {
|
||||
let full = Color::cmyk(1.0, 1.0, 1.0, 1.0);
|
||||
let paint = Paint::Solid { color: full.tinted(tint), swatch: Some(vectorcraft_color::swatch::REGISTRATION.into()), tint };
|
||||
return Some((paint, None));
|
||||
}
|
||||
let n = before.len();
|
||||
let kind = after.get(1).copied();
|
||||
let color = if rgb.unwrap_or(kind == Some(1.0)) {
|
||||
@@ -102,9 +111,20 @@ fn named(vals: &[V], rgb: Option<bool>) -> Option<(Paint, Option<Named>)> {
|
||||
|
||||
/// `… (name) tint Xs` / `… (name) tint type Xz`: the built-in Registration swatch at the tint (its
|
||||
/// own colour, whatever values the operands give).
|
||||
fn registration(vals: &[V]) -> Option<(Paint, Option<Named>)> {
|
||||
let at = vals.iter().rposition(|v| matches!(v, V::Str(_)))?;
|
||||
let tint = 1.0 - vals.get(at + 1).and_then(V::num).unwrap_or(0.0).clamp(0.0, 1.0) as f32;
|
||||
fn registration(op: &str, vals: &[V]) -> Option<(Paint, Option<Named>)> {
|
||||
let at = vals.iter().rposition(|v| matches!(v, V::Str(_) | V::Name(_)));
|
||||
let tint = if let Some(i) = at {
|
||||
1.0 - vals.get(i + 1).and_then(V::num).unwrap_or(0.0).clamp(0.0, 1.0) as f32
|
||||
} else {
|
||||
let mut nums = vals.iter().rev().filter_map(V::num);
|
||||
let t = if op.eq_ignore_ascii_case("xz") {
|
||||
let _type = nums.next();
|
||||
nums.next().unwrap_or(0.0)
|
||||
} else {
|
||||
nums.next().unwrap_or(0.0)
|
||||
};
|
||||
1.0 - t.clamp(0.0, 1.0) as f32
|
||||
};
|
||||
let full = Color::cmyk(1.0, 1.0, 1.0, 1.0);
|
||||
Some((Paint::Solid { color: full.tinted(tint), swatch: Some(vectorcraft_color::swatch::REGISTRATION.into()), tint }, None))
|
||||
}
|
||||
@@ -512,6 +532,29 @@ mod tests {
|
||||
let mut v = nums(&[1.0, 1.0, 1.0, 1.0]);
|
||||
v.extend([V::Str(b"[Registration]".to_vec()), V::Num(0.0)]);
|
||||
assert!(matches!(color_op("Xs", &v), Some((Paint::Solid { tint, .. }, None)) if tint == 1.0));
|
||||
let mut v = nums(&[1.0, 1.0, 1.0, 1.0]);
|
||||
v.extend([V::Str(b"[Registration]".to_vec()), V::Num(1.0)]);
|
||||
assert!(
|
||||
matches!(color_op("Xs", &v), Some((Paint::Solid { tint, color, .. }, None)) if tint == 0.0 && color == Color::cmyk(1.0, 1.0, 1.0, 1.0).tinted(0.0))
|
||||
);
|
||||
let mut v = nums(&[1.0, 1.0, 1.0, 1.0]);
|
||||
v.extend([V::Name("All".into()), V::Num(1.0)]);
|
||||
assert!(matches!(color_op("Xs", &v), Some((Paint::Solid { tint, .. }, None)) if tint == 0.0));
|
||||
let mut v = nums(&[1.0, 1.0, 1.0, 1.0]);
|
||||
v.extend([V::Str(b"[Registration]".to_vec()), V::Num(0.2), V::Num(0.0)]);
|
||||
let (p, named) = color_op("Xx", &v).unwrap();
|
||||
assert!(named.is_none());
|
||||
assert!(matches!(p, Paint::Solid { swatch: Some(s), tint, .. } if s == "[Registration]" && (tint - 0.8).abs() < 1e-6));
|
||||
let mut v = nums(&[1.0, 1.0, 1.0, 1.0]);
|
||||
v.extend([V::Name("All".into()), V::Num(0.5), V::Num(0.0)]);
|
||||
let (p, named) = color_op("Xx", &v).unwrap();
|
||||
assert!(named.is_none());
|
||||
assert!(matches!(p, Paint::Solid { swatch: Some(s), tint, .. } if s == "[Registration]" && (tint - 0.5).abs() < 1e-6));
|
||||
// Name-free Xs and Xz operands
|
||||
let v = nums(&[1.0, 1.0, 1.0, 1.0, 0.3]);
|
||||
assert!(matches!(color_op("Xs", &v), Some((Paint::Solid { tint, .. }, None)) if (tint - 0.7).abs() < 1e-6));
|
||||
let v = nums(&[1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.2, 1.0]);
|
||||
assert!(matches!(color_op("Xz", &v), Some((Paint::Solid { tint, .. }, None)) if (tint - 0.8).abs() < 1e-6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -528,6 +571,14 @@ mod tests {
|
||||
assert_eq!(named.name, "MySpot");
|
||||
assert!((named.tint - 0.6).abs() < 1e-6);
|
||||
assert!(matches!(p, Paint::Solid { swatch: Some(_), .. }));
|
||||
let mut v_name = nums(&[0.0, 0.5, 1.0, 0.0]);
|
||||
v_name.push(V::Name("MySpot".into()));
|
||||
v_name.push(V::Num(0.4));
|
||||
let (p, named) = color_op("x", &v_name).unwrap();
|
||||
let named = named.unwrap();
|
||||
assert_eq!(named.name, "MySpot");
|
||||
assert!((named.tint - 0.6).abs() < 1e-6);
|
||||
assert!(matches!(p, Paint::Solid { swatch: Some(_), .. }));
|
||||
assert!(color_op("k", &nums(&[1.0])).is_none());
|
||||
// A global process colour: CMYK (type 0) or RGB (type 1), not an ink.
|
||||
let mut v = nums(&[0.0, 0.0, 0.0, 1.0]);
|
||||
|
||||
@@ -43,12 +43,26 @@ impl<'a> Reader<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `X=` in a `/Mask` dictionary: the opacity mask's art starts, read until its `X+`
|
||||
/// ([`Self::end_mask`]).
|
||||
fn open_mask_object(&mut self, hidden: bool) -> Result<(), String> {
|
||||
if !matches!(self.frames.last(), Some(Frame { kind: Kind::Obj(o), .. }) if o.ty == "Mask") {
|
||||
// `X=` also introduces legacy text frame art, read by `text_frames`.
|
||||
return Ok(());
|
||||
}
|
||||
self.end_path_quietly();
|
||||
// The art goes on from the paint before the dictionaries it is in, which `begin_obj` set
|
||||
// aside: the writer leaves out paint that doesn't change, so a mask rectangle with no color
|
||||
// of its own takes the white of the art before it, not a fresh state's black, which hides
|
||||
// what it masks. Lock, visibility and transparency start afresh, as a dictionary's art does.
|
||||
if let Some(f) = self.frames.iter().rev().take_while(|f| matches!(f.kind, Kind::Obj(_))).last() {
|
||||
let before = &f.gs;
|
||||
let gs = &mut self.gs;
|
||||
(gs.fill, gs.stroke, gs.dash) = (before.fill.clone(), before.stroke.clone(), before.dash.clone());
|
||||
(gs.width, gs.cap, gs.join, gs.miter, gs.rule) = (before.width, before.cap, before.join, before.miter, before.rule);
|
||||
(gs.overprint_fill, gs.overprint_stroke) = (before.overprint_fill, before.overprint_stroke);
|
||||
(self.pending_unread_fill, self.pending_unread_stroke) = (f.pending_unread_fill, f.pending_unread_stroke);
|
||||
}
|
||||
self.open(Kind::Mask, hidden)
|
||||
}
|
||||
|
||||
@@ -509,7 +523,7 @@ impl<'a> Reader<'a> {
|
||||
fn color(&mut self, w: &str, vals: &[V]) {
|
||||
let Some((p, named)) = paint::color_op(w, vals) else { return };
|
||||
// Registration (`Xs`, `Xz`) says nothing of the document's colour model.
|
||||
let registration = matches!(w, "Xs" | "XS" | "Xz" | "XZ");
|
||||
let registration = matches!(w, "Xs" | "XS" | "Xz" | "XZ") || p.is_registration();
|
||||
match p.color().filter(|_| !registration) {
|
||||
Some(vectorcraft_color::Color::Rgb { .. }) => self.rgb_colors += 1,
|
||||
Some(vectorcraft_color::Color::Cmyk { .. }) => self.cmyk_colors += 1,
|
||||
@@ -1189,9 +1203,9 @@ pub(crate) fn read_art_style(section: &[u8], name: &str, own_gradient: Option<&P
|
||||
|
||||
/// A style's stroke as an appearance item (`None` for a fill).
|
||||
fn stroke_layer(item: &crate::import::ai::style::StyleItem) -> Option<AppearanceItem> {
|
||||
let crate::import::ai::style::StyleItem::Stroke { paint, width, cap, join, miter, dash, profile, brush } = item else { return None };
|
||||
let crate::import::ai::style::StyleItem::Stroke { paint, width, cap, join, miter, dash, profile, align, brush } = item else { return None };
|
||||
let mut layer = StrokeLayer::new(paint.clone(), line_width(*width));
|
||||
(layer.cap, layer.join, layer.miter_limit, layer.dash, layer.profile) = (*cap, *join, *miter, dash.clone(), profile.clone());
|
||||
(layer.cap, layer.join, layer.miter_limit, layer.dash, layer.profile, layer.align) = (*cap, *join, *miter, dash.clone(), profile.clone(), *align);
|
||||
layer.brush = brush.as_ref().map(|(name, _)| name.clone());
|
||||
Some(AppearanceItem::Stroke(layer))
|
||||
}
|
||||
|
||||
@@ -36,8 +36,15 @@
|
||||
//! Tool)` or `(Adobe ArtOnPath Brush Tool)`, with its stroke's style and the brush's settings for
|
||||
//! the object as entries (see [`super::brush`]).
|
||||
//!
|
||||
//! A stroke aligned inside or outside its path (Stroke panel › Align Stroke,
|
||||
//! https://helpx.adobe.com/illustrator/desktop/draw-and-paint/use-strokes/stroke-panel-options.html)
|
||||
//! is its own basic filter too, `(Adobe Stroke Offset)`, with its stroke's style and
|
||||
//! `StrokeOffsetInside` (1: inside, 0: outside, as its `DisplayString` names it) as entries.
|
||||
//! Without that entry, or with it as anything but a `/Bool` 0 or 1, which side it paints isn't
|
||||
//! known and the style isn't plain (its objects keep their drawn look).
|
||||
//!
|
||||
//! A style is *plain* when every filter in it is one read here: fills and strokes (in a colour; fills
|
||||
//! with a pattern or gradient too; strokes with a Calligraphic or Art brush too), each with its own
|
||||
//! with a pattern or gradient too; strokes with a Calligraphic or Art brush or aligned inside or outside too), each with its own
|
||||
//! transparency and effects, and the effects of
|
||||
//! [`effect`]. Such objects are read as themselves with an appearance of those fills, strokes and
|
||||
//! effects; others keep their drawn look. Effect › Pathfinder effects (`(Adobe Pathfinder)` in the
|
||||
@@ -47,12 +54,12 @@ use std::collections::HashMap;
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_color::{BlendMode, Paint};
|
||||
use vectorcraft_doc::{Dash, Effect, Knockout, LineCap, LineJoin, WidthProfile};
|
||||
use vectorcraft_doc::{Dash, Effect, Knockout, LineCap, LineJoin, StrokeAlign, WidthProfile};
|
||||
use vectorcraft_effects::is_pathfinder;
|
||||
|
||||
use super::brush::PathBrush;
|
||||
use super::lex::{Lexer, Tok};
|
||||
use super::obj::V;
|
||||
use super::obj::{self, V};
|
||||
use super::paint;
|
||||
|
||||
const GLOW: &str = "Adobe Outer Glow"; // brand-ok: the filter's name in the files
|
||||
@@ -78,6 +85,8 @@ const BAS_RELIEF: &str = "PSAdapter_plugin_BsRl";
|
||||
const SHAPE_EFFECTS: &str = "Adobe Shape Effects"; // brand-ok: the filter's name in the files
|
||||
const PATHFINDER: &str = "Adobe Pathfinder"; // brand-ok: the filter's name in the files
|
||||
|
||||
/// A stroke aligned inside or outside its path (Stroke panel › Align Stroke).
|
||||
const STROKE_OFFSET: &str = "Adobe Stroke Offset"; // brand-ok: the filter's name in the files
|
||||
const CALLIGRAPHIC: &str = "Adobe Calligraphic Brush Tool"; // brand-ok: the filter's and plug-in's name in the files
|
||||
const ART_BRUSH: &str = "Adobe ArtOnPath Brush Tool"; // brand-ok: the filter's and plug-in's name in the files
|
||||
|
||||
@@ -211,6 +220,8 @@ pub(super) enum StyleItem {
|
||||
miter: f64,
|
||||
dash: Option<Dash>,
|
||||
profile: Option<WidthProfile>,
|
||||
/// Which side of the path it paints (centred, or inside or outside a closed path).
|
||||
align: StrokeAlign,
|
||||
/// A brush: its name, and its settings for this object.
|
||||
brush: Option<(String, PathBrush)>,
|
||||
},
|
||||
@@ -421,6 +432,7 @@ pub(super) fn art_styles(data: &[u8]) -> HashMap<String, ArtStyle> {
|
||||
b"FillStyle" => style_entry = Some(Kind::Fill),
|
||||
b"StrokeStyle" | b"BlendStyle" => style_entry = Some(Kind::Other),
|
||||
b"Name" if name.is_none() && filters.is_empty() => name = operands.iter().rev().find_map(V::text),
|
||||
b"All" | b"[Registration]" | b"Registration" => operands.push(V::Name(obj::text(n))),
|
||||
b"CompoundFilter" => pending = Some(true),
|
||||
b"BasicFilter" => pending = Some(false),
|
||||
b"Part" => in_basic = false,
|
||||
@@ -594,7 +606,8 @@ fn block_data(lex: &mut Lexer, encoded: &[u8]) -> Option<Vec<u8>> {
|
||||
fn paint_op(f: &mut Filter, op: &str, vals: &[V]) {
|
||||
let last = vals.iter().rev().find_map(V::num);
|
||||
match op {
|
||||
"Xa" | "k" | "g" | "x" | "Xk" | "XA" | "K" | "G" | "X" | "XK" => {
|
||||
"Xa" | "k" | "g" | "x" | "Xx" | "Xk" | "Xs" | "Xz" | "XA" | "K" | "G" | "X" | "XX" | "XK" | "XS" | "XZ" | "xa" | "xx" | "xk" | "xs"
|
||||
| "xz" => {
|
||||
let Some((p, _)) = paint::color_op(op, vals) else { return };
|
||||
if op.ends_with(|c: char| c.is_ascii_lowercase()) {
|
||||
f.fill = Some(p);
|
||||
@@ -1211,29 +1224,49 @@ fn paint_item(f: &Filter) -> Result<Option<StyleItem>, ()> {
|
||||
(None, None, Some(p)) => Ok(Some(StyleItem::Fill(p.clone()))),
|
||||
(None, None, None) => Err(()),
|
||||
},
|
||||
STROKE if !f.paints.0 => Ok(None),
|
||||
// Dashes aligned to corners and path ends draw as the look's pieces: a solid end at each end
|
||||
// and the pattern, fitted, between them.
|
||||
STROKE if f.params.get("BSDashAdj").is_some_and(|v| *v != 0.0) => Err(()),
|
||||
STROKE => match &f.stroke {
|
||||
Some(p) if !f.gradient && f.pattern.is_none() => Ok(Some(StyleItem::Stroke {
|
||||
paint: p.clone(),
|
||||
width: f.width,
|
||||
cap: f.cap,
|
||||
join: f.join,
|
||||
miter: f.miter,
|
||||
dash: f.dash.clone(),
|
||||
profile: width_profile(&f.arrays),
|
||||
brush: None,
|
||||
})),
|
||||
_ => Err(()),
|
||||
},
|
||||
STROKE => stroke_item(f, StrokeAlign::Center),
|
||||
CALLIGRAPHIC => calligraphic_stroke(f).map(Some).ok_or(()),
|
||||
STROKE_OFFSET => offset_stroke(f),
|
||||
ART_BRUSH => art_stroke(f).map(Some).ok_or(()),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A stroke filter `f`'s stroke, painting on side `align` of its path: `Ok(None)` when it paints
|
||||
/// nothing, `Err` when it paints in a way this doesn't read.
|
||||
fn stroke_item(f: &Filter, align: StrokeAlign) -> Result<Option<StyleItem>, ()> {
|
||||
if !f.paints.0 {
|
||||
return Ok(None);
|
||||
}
|
||||
// Dashes aligned to corners and path ends draw as the look's pieces: a solid end at each end
|
||||
// and the pattern, fitted, between them.
|
||||
if f.params.get("BSDashAdj").is_some_and(|v| *v != 0.0) {
|
||||
return Err(());
|
||||
}
|
||||
match &f.stroke {
|
||||
Some(p) if !f.gradient && f.pattern.is_none() => Ok(Some(StyleItem::Stroke {
|
||||
paint: p.clone(),
|
||||
width: f.width,
|
||||
cap: f.cap,
|
||||
join: f.join,
|
||||
miter: f.miter,
|
||||
dash: f.dash.clone(),
|
||||
profile: width_profile(&f.arrays),
|
||||
align,
|
||||
brush: None,
|
||||
})),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A Stroke Offset filter `f`: a stroke whose own style is in its dictionary, aligned inside its
|
||||
/// path when `StrokeOffsetInside` is 1 and outside when it is 0 (its `DisplayString` says which).
|
||||
/// `Err` when that switch is missing or not a switch, or for a stroke [`paint_item`] doesn't read.
|
||||
fn offset_stroke(f: &Filter) -> Result<Option<StyleItem>, ()> {
|
||||
let inside = switch(f, "StrokeOffsetInside", Kind::Bool)?.ok_or(())?;
|
||||
stroke_item(f, if inside { StrokeAlign::Inside } else { StrokeAlign::Outside })
|
||||
}
|
||||
|
||||
/// A Calligraphic brush's filter `f` as the stroke it paints: `None` when it isn't a stroke in a
|
||||
/// colour, names no Calligraphic brush, or its settings aren't all there as numbers. An angle
|
||||
/// relative to the path isn't read.
|
||||
@@ -1261,6 +1294,7 @@ fn calligraphic_stroke(f: &Filter) -> Option<StyleItem> {
|
||||
miter: f.miter,
|
||||
dash: None,
|
||||
profile: None,
|
||||
align: StrokeAlign::Center,
|
||||
brush: Some((name.clone(), PathBrush::Calligraphic(super::brush::calligraphic(values, variation, codes)))),
|
||||
})
|
||||
}
|
||||
@@ -1339,6 +1373,7 @@ fn art_stroke(f: &Filter) -> Option<StyleItem> {
|
||||
miter: f.miter,
|
||||
dash: None,
|
||||
profile: None,
|
||||
align: StrokeAlign::Center,
|
||||
brush: Some((name.clone(), PathBrush::Art)),
|
||||
})
|
||||
}
|
||||
@@ -1383,7 +1418,7 @@ impl Builder<'_> {
|
||||
let c = &filters[k];
|
||||
match c.name.as_str() {
|
||||
_ if c.compound => {}
|
||||
FILL | STROKE | CALLIGRAPHIC | ART_BRUSH => match paint_item(c) {
|
||||
FILL | STROKE | STROKE_OFFSET | CALLIGRAPHIC | ART_BRUSH => match paint_item(c) {
|
||||
Ok(Some(StyleItem::Stroke { .. })) => under.strokes = true,
|
||||
Ok(Some(StyleItem::Pattern { .. })) => under.patterns = true,
|
||||
_ => {}
|
||||
@@ -1397,7 +1432,7 @@ impl Builder<'_> {
|
||||
let c = &filters[k];
|
||||
match c.name.as_str() {
|
||||
_ if c.compound => self.plain = false,
|
||||
FILL | STROKE | CALLIGRAPHIC | ART_BRUSH => {
|
||||
FILL | STROKE | STROKE_OFFSET | CALLIGRAPHIC | ART_BRUSH => {
|
||||
paints += 1;
|
||||
look.visible &= c.visible;
|
||||
match paint_item(c) {
|
||||
@@ -1518,7 +1553,7 @@ fn style(filters: &[Filter]) -> ArtStyle {
|
||||
let f = &filters[id];
|
||||
match f.name.as_str() {
|
||||
STACK if f.compound => b.stack(id, &mut st),
|
||||
FILL | STROKE | CALLIGRAPHIC | ART_BRUSH | CHAIN => {
|
||||
FILL | STROKE | STROKE_OFFSET | CALLIGRAPHIC | ART_BRUSH | CHAIN => {
|
||||
if let Some((item, look)) = b.item(id) {
|
||||
st.items.push(item);
|
||||
st.looks.push(look);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use vectorcraft_color::Color;
|
||||
|
||||
#[test]
|
||||
fn a_variable_width_stroke_keeps_its_profile() {
|
||||
@@ -1393,3 +1394,52 @@ fn a_style_is_read_as_a_writer_checks_it() {
|
||||
let _ = read(junk, Some(&own));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn art_styles_read_registration_and_named_colors() {
|
||||
let reg_0 = fill("1 1 1 1 ([Registration]) 1 Xs");
|
||||
let reg_100 = fill("1 1 1 1 ([Registration]) 0 Xs");
|
||||
let reg_name = fill("1 1 1 1 /All 1 Xs");
|
||||
let named_spot = fill("0 0.5 1 0 (Shemen) 0 0 Xx");
|
||||
let stroke_reg = basic(
|
||||
"Stroke Style Filter",
|
||||
"/StrokeStyle : 0 R\n1 1 1 1 ([Registration]) 1 XS\n0 1 0 0 0 Xy\n0 J 0 j 1 w 10 M []0 d\n0 XR\n1 0 Xd\n/Def ;\n (StrokeStyle) ,\n",
|
||||
);
|
||||
let data = format!(
|
||||
"/KnownStyle :\n(Reg0) /Name ,\n/ActiveStyle :\n{}\n\
|
||||
/KnownStyle :\n(Reg100) /Name ,\n/ActiveStyle :\n{}\n\
|
||||
/KnownStyle :\n(RegName) /Name ,\n/ActiveStyle :\n{}\n\
|
||||
/KnownStyle :\n(Shemen) /Name ,\n/ActiveStyle :\n{}\n\
|
||||
/KnownStyle :\n(StrokeReg) /Name ,\n/ActiveStyle :\n{}\n",
|
||||
compound("Chain Style Filter", &[compound("Stack Style Filter", &[reg_0])]),
|
||||
compound("Chain Style Filter", &[compound("Stack Style Filter", &[reg_100])]),
|
||||
compound("Chain Style Filter", &[compound("Stack Style Filter", &[reg_name])]),
|
||||
compound("Chain Style Filter", &[compound("Stack Style Filter", &[named_spot])]),
|
||||
compound("Chain Style Filter", &[compound("Stack Style Filter", &[stroke_reg])]),
|
||||
);
|
||||
let styles = art_styles(data.as_bytes());
|
||||
let st0 = &styles["Reg0"];
|
||||
assert!(st0.plain);
|
||||
let [StyleItem::Fill(Paint::Solid { swatch, tint, color })] = st0.items.as_slice() else { panic!("{st0:?}") };
|
||||
assert_eq!((swatch.as_deref(), *tint, *color), (Some(vectorcraft_color::swatch::REGISTRATION), 0.0, Color::cmyk(1.0, 1.0, 1.0, 1.0).tinted(0.0)));
|
||||
|
||||
let st100 = &styles["Reg100"];
|
||||
assert!(st100.plain);
|
||||
let [StyleItem::Fill(Paint::Solid { swatch, tint, color })] = st100.items.as_slice() else { panic!("{st100:?}") };
|
||||
assert_eq!((swatch.as_deref(), *tint, *color), (Some(vectorcraft_color::swatch::REGISTRATION), 1.0, Color::cmyk(1.0, 1.0, 1.0, 1.0)));
|
||||
|
||||
let st_name = &styles["RegName"];
|
||||
assert!(st_name.plain);
|
||||
let [StyleItem::Fill(Paint::Solid { swatch, tint, .. })] = st_name.items.as_slice() else { panic!("{st_name:?}") };
|
||||
assert_eq!((swatch.as_deref(), *tint), (Some(vectorcraft_color::swatch::REGISTRATION), 0.0));
|
||||
|
||||
let st_spot = &styles["Shemen"];
|
||||
assert!(st_spot.plain);
|
||||
let [StyleItem::Fill(Paint::Solid { swatch, tint, color })] = st_spot.items.as_slice() else { panic!("{st_spot:?}") };
|
||||
assert_eq!((swatch.as_deref(), *tint, *color), (Some("Shemen"), 1.0, Color::cmyk(0.0, 0.5, 1.0, 0.0)));
|
||||
|
||||
let st_stroke = &styles["StrokeReg"];
|
||||
assert!(st_stroke.plain);
|
||||
let [StyleItem::Stroke { paint: Paint::Solid { swatch, tint, .. }, .. }] = st_stroke.items.as_slice() else { panic!("{st_stroke:?}") };
|
||||
assert_eq!((swatch.as_deref(), *tint), (Some(vectorcraft_color::swatch::REGISTRATION), 0.0));
|
||||
}
|
||||
|
||||
@@ -5778,6 +5778,111 @@ fn an_object_with_a_style_this_reads_comes_in_as_itself() {
|
||||
assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// An art style named `name`: a blue fill under a red 2 pt stroke written as a Stroke Offset filter
|
||||
/// whose `StrokeOffsetInside` entry is `inside` (`None`: left out).
|
||||
fn offset_stroke_style(name: &str, inside: Option<u8>) -> String {
|
||||
let switch = inside.map(|v| format!("{v} /Bool (StrokeOffsetInside) ,\n")).unwrap_or_default();
|
||||
offset_stroke_style_with(name, &switch, OFFSET_STROKE)
|
||||
}
|
||||
|
||||
/// The Stroke Offset stroke of [`offset_stroke_style`]: red, 2 pt, butt caps, miter joins, solid.
|
||||
const OFFSET_STROKE: &str = "0 R\n0 1 1 0 1 0 0 XA\n0 J 0 j 2 w 10 M []0 d\n0 XR\n1 0 Xd";
|
||||
|
||||
/// An art style named `name`: a blue fill under a Stroke Offset filter whose dictionary holds
|
||||
/// `entries` and the stroke style `stroke` (what follows `/StrokeStyle :`).
|
||||
fn offset_stroke_style_with(name: &str, entries: &str, stroke: &str) -> String {
|
||||
format!(
|
||||
"/KnownStyle :\n({name}) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
|
||||
/CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
|
||||
/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
|
||||
/BasicFilter :\n(Adobe Stroke Offset) 1 0 /Filter ,\n1 /Visible ,\n2 /FillOrStroke ,\n/Dictionary : /NotRecorded ,\n{entries}(Side) /String (DisplayString) ,\n\
|
||||
/StrokeStyle : {stroke}\n/Def ;\n (StrokeStyle) ,\n; /Dict ;\n /Part ,\n;\n /Part ,\n\
|
||||
/BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n/FillStyle : 0 O\n0 0 0 0 0 0 1 Xa\n0 1 Xd\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n\
|
||||
/BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n;\n /Part ,\n;\n"
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stroke_offset_filter_is_a_stroke_aligned_inside_or_outside() {
|
||||
// GUARDS: a style's stroke aligned inside or outside (Stroke panel › Align Stroke) left the object as its drawn look.
|
||||
use vectorcraft_doc::StrokeAlign;
|
||||
for (inside, align) in [(Some(1), Some(StrokeAlign::Inside)), (Some(0), Some(StrokeAlign::Outside)), (None, None), (Some(2), None)] {
|
||||
let r = read_ok(&format!("%AI9_BeginArtStyles\n{}%AI9_EndArtStyles\n{}", offset_stroke_style("Edge", inside), layer(&styled_square("Edge"))));
|
||||
let a = art(&r.doc);
|
||||
assert_eq!(a.len(), 1, "{a:#?}");
|
||||
let stroke = a[0].appearance.stroke().map(|s| (s.align, s.width, s.paint.color()));
|
||||
match align {
|
||||
Some(align) => {
|
||||
assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the object, not its look");
|
||||
assert_eq!(stroke, Some((align, 2.0, Some(Color::rgb(1.0, 0.0, 0.0)))));
|
||||
assert_eq!(fill(&a[0]).color(), Some(Color::rgb(0.0, 0.0, 1.0)));
|
||||
assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings);
|
||||
}
|
||||
// Without a 0/1 switch, which side it paints isn't known: the drawn look stays.
|
||||
None => assert!(r.warnings.iter().any(|w| w.contains("drawn look")), "{inside:?}: {:?}", r.warnings),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A Stroke Offset stroke keeps the caps, joins, miter limit, dashes and width profile of its
|
||||
/// style, as a Stroke Style Filter's does.
|
||||
#[test]
|
||||
fn a_stroke_offset_filter_keeps_its_strokes_style() {
|
||||
use vectorcraft_doc::StrokeAlign;
|
||||
let profile = "/Dictionary : /NotRecorded ,\n/Array :\n0 /Real ,\n1 /Real ,\n; (BSVariableWidthTValuesArray) ,\n\
|
||||
/Array :\n1 /Real ,\n0.1 /Real ,\n; (BSCenteredWidthsArray) ,\n; (kAIBeautifulStrokesParamsDictKey) ,\n";
|
||||
let entries = format!("0 /Bool (StrokeOffsetInside) ,\n{profile}");
|
||||
let stroke = "0 R\n0 1 1 0 1 0 0 XA\n1 J 2 j 3 w 4 M [5 10 ]0 d\n0 XR\n1 0 Xd";
|
||||
let style = offset_stroke_style_with("Edge", &entries, stroke);
|
||||
let r = read_ok(&format!("%AI9_BeginArtStyles\n{style}%AI9_EndArtStyles\n{}", layer(&styled_square("Edge"))));
|
||||
let a = art(&r.doc);
|
||||
assert_eq!(a.len(), 1, "{a:#?}");
|
||||
assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the object, not its look");
|
||||
let s = a[0].appearance.stroke().expect("a stroke");
|
||||
assert_eq!((s.align, s.width, s.cap, s.join, s.miter_limit), (StrokeAlign::Outside, 3.0, LineCap::Round, LineJoin::Bevel, 4.0));
|
||||
assert_eq!(s.dash.as_ref().map(|d| d.pattern.clone()), Some(vec![5.0, 10.0]));
|
||||
assert_eq!(s.profile.as_ref().map(|p| p.points.clone()), Some(vec![(0.0, 1.0, 1.0), (1.0, 0.1, 0.1)]));
|
||||
}
|
||||
|
||||
/// A Stroke Offset stroke this doesn't read as a stroke keeps the object's drawn look: a switch
|
||||
/// written as another kind, dashes aligned to corners and path ends, a gradient.
|
||||
#[test]
|
||||
fn a_stroke_offset_filter_with_a_stroke_this_does_not_read_keeps_the_drawn_look() {
|
||||
let outside = "0 /Bool (StrokeOffsetInside) ,\n";
|
||||
let gradient = "0 R\nBb\n0 0 0 0 Bh\n2 (Red ramp) 0 0 90 1 1 0 0 1 0 0 1 Bg\n0 BB\n0 J 0 j 2 w 10 M []0 d\n0 XR\n1 0 Xd";
|
||||
let cases = [
|
||||
("a switch written as a number", offset_stroke_style_with("Edge", "1 /Int (StrokeOffsetInside) ,\n", OFFSET_STROKE)),
|
||||
(
|
||||
"dashes aligned to corners",
|
||||
offset_stroke_style_with(
|
||||
"Edge",
|
||||
&format!("{outside}/Dictionary : /NotRecorded ,\n1 /Bool (BSDashAdj) ,\n; (kAIBeautifulStrokesParamsDictKey) ,\n"),
|
||||
&OFFSET_STROKE.replace("[]0 d", "[5 10 ]0 d"),
|
||||
),
|
||||
),
|
||||
("a gradient", offset_stroke_style_with("Edge", outside, gradient)),
|
||||
];
|
||||
for (why, style) in cases {
|
||||
let r = read_ok(&format!("%AI9_BeginArtStyles\n{style}%AI9_EndArtStyles\n{}", layer(&styled_square("Edge"))));
|
||||
assert!(r.warnings.iter().any(|w| w.contains("drawn look")), "{why}: {:?}", r.warnings);
|
||||
}
|
||||
}
|
||||
|
||||
/// A listed `[Default]` style whose stroke is aligned outside comes in as the default graphic
|
||||
/// style with that stroke.
|
||||
#[test]
|
||||
fn a_default_style_with_a_stroke_aligned_outside_comes_in() {
|
||||
use vectorcraft_doc::StrokeAlign;
|
||||
let r =
|
||||
read_ok(&format!("{}{}", styles_and_list(&[offset_stroke_style("[Default]", Some(0))], &["[Default]"]), layer(&styled_square("[Default]"))));
|
||||
assert_eq!(style_names(&r.doc), [("[Default]".to_string(), 1)]);
|
||||
let stroke = r.doc.graphic_styles[0].appearance.stroke().map(|s| (s.align, s.width));
|
||||
assert_eq!(stroke, Some((StrokeAlign::Outside, 2.0)));
|
||||
let a = art(&r.doc);
|
||||
assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the object, not its look");
|
||||
assert_eq!(a[0].extras().graphic_style, Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_symbol_defined_before_the_art_styles_reads_its_styled_objects() {
|
||||
// GUARDS: objects in a symbol defined ahead of `%AI9_BeginArtStyles` (as Illustrator 24 writes
|
||||
@@ -5964,6 +6069,30 @@ fn an_object_whose_transparency_differs_from_its_listed_style_is_not_linked() {
|
||||
assert_eq!(a[0].extras().graphic_style, None, "the style has full opacity");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ai_import_registration_style_does_not_carry_over_previous_color_6205() {
|
||||
// Regression test for #6205:
|
||||
// A background shape sets a spot colour ("Shemen").
|
||||
// A following object has an art style with [Registration] at 0% tint.
|
||||
// The object must resolve the Registration style ([Registration] 0% / #ffffff)
|
||||
// rather than falling back to the prior spot color.
|
||||
let defs = [fill_style("RegWhite", "1 1 1 1 ([Registration]) 1 Xs\n")];
|
||||
let background = format!("0 0.67 0.73 0.26 (Shemen) 0 0 Xx\n{}f\n", rect_path(0.0, 0.0, 100.0, 100.0));
|
||||
let styled_obj = styled_square("RegWhite");
|
||||
let content = format!("{background}{styled_obj}");
|
||||
let r = read_ok(&format!("{}{}", styles_and_list(&defs, &["RegWhite"]), layer(&content)));
|
||||
let a = art(&r.doc);
|
||||
assert_eq!(a.len(), 2, "{a:#?}");
|
||||
// First object is background in Shemen spot
|
||||
let Paint::Solid { swatch: s0, .. } = fill(&a[0]) else { panic!("{:#?}", a[0]) };
|
||||
assert_eq!(s0.as_deref(), Some("Shemen"));
|
||||
// Second object is styled in RegWhite: [Registration] at 0% tint (#ffffff)
|
||||
let Paint::Solid { swatch, tint, color } = fill(&a[1]) else { panic!("{:#?}", a[1]) };
|
||||
assert_eq!(swatch.as_deref(), Some(vectorcraft_color::swatch::REGISTRATION));
|
||||
assert_eq!(tint, 0.0);
|
||||
assert_eq!(color, Color::cmyk(1.0, 1.0, 1.0, 1.0).tinted(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_listed_style_keeps_its_transparency_and_links_objects_that_have_it() {
|
||||
let blend = "/BasicFilter :\n(Blend Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n\
|
||||
@@ -7519,6 +7648,53 @@ fn vector_opacity_mask_keeps_its_art_and_does_not_paint_as_a_sibling() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Mask art that sets no paint of its own paints with the paint before its dictionary: the writer
|
||||
/// leaves out paint that doesn't change. In a user's file, white letters were masked by a rectangle
|
||||
/// with no colour, which came in black and hid them.
|
||||
/// The unread pattern flags it also takes aren't covered here: an owner painted with a pattern the
|
||||
/// file doesn't have is unread itself, which hides whether its mask art is.
|
||||
#[test]
|
||||
fn opacity_mask_art_without_its_own_paint_takes_the_paint_before_its_dictionary() {
|
||||
let paint = "0 0 0 0 k\n1 O\n0 1 0 0 K\n1 R\n2 w\n1 J\n1 j\n4 M\n[3 1] 0 d\n1 XR\n";
|
||||
let owner = format!("{paint}{}B\n", rect_path(10.0, 20.0, 50.0, 60.0));
|
||||
let hidden_rect = rect_path(10.0, 20.0, 30.0, 60.0).lines().map(|l| format!("%_{l}\n")).collect::<String>();
|
||||
let mask = |own_paint: &str| {
|
||||
format!("%_/ArtDictionary :\n%_/Mask :\n%_X=\n{own_paint}{hidden_rect}%_B\n%_X+\n%_(Art) ,\n%_1 /Bool (Clipping) ,\n%_; (AI9_Mask) ,\n%_;\n")
|
||||
};
|
||||
let fill_layer = |n: &Node| n.appearance.fill().cloned();
|
||||
let stroke_layer = |n: &Node| n.appearance.stroke().cloned();
|
||||
let rule = |n: &Node| match &n.kind {
|
||||
NodeKind::Path { rule, .. } => Some(*rule),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let r = read_ok(&layer(&format!("{owner}{}", mask(""))));
|
||||
let a = art(&r.doc);
|
||||
let m = a[0].extras().mask.as_ref().unwrap();
|
||||
// The owner has the paint set, none of it the default, so equal layers mean it was taken.
|
||||
let (f, s) = (fill_layer(&a[0]).unwrap(), stroke_layer(&a[0]).unwrap());
|
||||
assert!(f.overprint && s.overprint);
|
||||
assert_eq!((s.width, s.cap, s.join, s.miter_limit), (2.0, LineCap::Round, LineJoin::Round, 4.0));
|
||||
assert_eq!(s.dash.as_ref().map(|d| (d.pattern.clone(), d.offset)), Some((vec![3.0, 1.0], 0.0)));
|
||||
assert_eq!(rule(&a[0]), Some(vectorcraft_geom::FillRule::EvenOdd));
|
||||
assert_eq!(fill_layer(&m.art), Some(f));
|
||||
assert_eq!(stroke_layer(&m.art), Some(s));
|
||||
assert_eq!(rule(&m.art), rule(&a[0]));
|
||||
|
||||
// Paint the mask's art sets for itself stays in the mask: an object after the dictionary that
|
||||
// sets none paints with the owner's.
|
||||
let own = "%_1 g\n%_0 O\n%_0 G\n%_0 R\n%_5 w\n%_0 J\n%_0 j\n%_10 M\n%_[] 0 d\n%_0 XR\n";
|
||||
let following = format!("{}B\n", rect_path(70.0, 20.0, 80.0, 30.0));
|
||||
let r = read_ok(&layer(&format!("{owner}{}{following}", mask(own))));
|
||||
let a = art(&r.doc);
|
||||
assert_eq!(a.len(), 2);
|
||||
let m = a[0].extras().mask.as_ref().unwrap();
|
||||
assert_ne!(stroke_layer(&m.art), stroke_layer(&a[0]), "the mask's art has its own paint");
|
||||
assert_eq!(fill_layer(&a[1]), fill_layer(&a[0]));
|
||||
assert_eq!(stroke_layer(&a[1]), stroke_layer(&a[0]));
|
||||
assert_eq!(rule(&a[1]), rule(&a[0]));
|
||||
}
|
||||
|
||||
/// An opacity mask's art of one shown object is that object; other art is read into a group.
|
||||
#[test]
|
||||
fn opacity_mask_art_of_one_object_is_that_object() {
|
||||
|
||||
@@ -42,7 +42,7 @@ const MAX_DASH_ENTRIES: usize = 65_535;
|
||||
/// Most entries an indexed colour space's table has.
|
||||
const MAX_HIVAL: usize = 4095;
|
||||
/// Most elements the current path may have.
|
||||
const MAX_PATH: usize = 1 << 20;
|
||||
pub(crate) const MAX_PATH: usize = 1 << 20;
|
||||
/// Most path elements drawn in all (each object's clips and groups counting as elements too).
|
||||
const MAX_DRAWN: usize = 1 << 24;
|
||||
/// Samples taken of a shading function that isn't a plain interpolation.
|
||||
|
||||
@@ -193,7 +193,7 @@ impl Interp<'_> {
|
||||
|
||||
// ---------- painting ----------
|
||||
|
||||
pub(super) fn fill_path(&mut self, bp: BezPath, rule: FillRule) -> Res {
|
||||
pub(in super::super) fn fill_path(&mut self, bp: BezPath, rule: FillRule) -> Res {
|
||||
if bp.elements().is_empty() || self.g.null {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -73,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,
|
||||
@@ -94,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;
|
||||
|
||||
@@ -189,6 +198,8 @@ impl<'a> Interp<'a> {
|
||||
journal: vec![],
|
||||
fault: None,
|
||||
glyph_width: None,
|
||||
type42: vec![],
|
||||
type42_counted: HashSet::new(),
|
||||
g,
|
||||
saved: vec![],
|
||||
out,
|
||||
@@ -362,7 +373,7 @@ impl<'a> Interp<'a> {
|
||||
|
||||
/// Charge for work on `n` bytes or objects in one operator: one operation per 256, as `search`
|
||||
/// charges per 64 bytes, so a loop of big copies runs out of the budget.
|
||||
fn spend_bytes(&mut self, n: usize) -> Res {
|
||||
pub fn spend_bytes(&mut self, n: usize) -> Res {
|
||||
self.spend((n / 256) as u64)
|
||||
}
|
||||
|
||||
|
||||
@@ -1125,7 +1125,7 @@ fn read_within(body: &str, secs: u64) -> crate::Imported {
|
||||
/// budget. Running to the end of the budget can take over 20 s on a slow CI runner
|
||||
/// (`search_in_a_loop_runs_out_of_the_budget` searches about 3 GB, 14 s on a fast desktop), so the
|
||||
/// limit here only catches a program that never stops.
|
||||
fn runs_out_of_budget(body: &str) {
|
||||
pub(super) fn runs_out_of_budget(body: &str) {
|
||||
let r = read_within(&format!("0 0 10 10 rectfill {body}"), 120);
|
||||
assert_eq!(objects(&r.document).len(), 1, "{body}: {:?}", r.warnings);
|
||||
assert!(r.warnings.iter().any(|w| w.contains("the program runs too long")), "{body}: {:?}", r.warnings);
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
//! Composite (Type 0) fonts with the 8/8 mapping, as PDF-to-PostScript converters write a PDF's
|
||||
//! two-byte fonts: each character a byte picking the descendant font and a byte for the code in
|
||||
//! it (PLRM 3rd ed., §5.10). Type 42 descendants draw their glyphs from the TrueType font they
|
||||
//! carry (Adobe Technical Note #5012); the fonts here are written from scratch around the bundled
|
||||
//! Source Sans 3.
|
||||
|
||||
use skrifa::instance::{LocationRef, Size};
|
||||
use skrifa::{FontRef, MetadataProvider};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::NodeKind;
|
||||
|
||||
use super::tests::{all, bounds, fill, read};
|
||||
|
||||
const FONT: &[u8] = include_bytes!("../../../../assets/fonts/SourceSans3-Regular.ttf");
|
||||
|
||||
/// The glyph index of `c` in [`FONT`] and its advance width in ems.
|
||||
fn glyph(c: char) -> (u32, f64) {
|
||||
let font = FontRef::new(FONT).unwrap();
|
||||
let gid = font.charmap().map(c).unwrap();
|
||||
let upem = f64::from(font.metrics(Size::unscaled(), LocationRef::default()).units_per_em);
|
||||
let adv = font.glyph_metrics(Size::unscaled(), LocationRef::default()).advance_width(gid).unwrap();
|
||||
(gid.to_u32(), f64::from(adv) / upem)
|
||||
}
|
||||
|
||||
/// `sfnts` strings of [`FONT`]: the first of odd length, ending in a padding byte that isn't part
|
||||
/// of the font, the rest split at even lengths.
|
||||
fn sfnts() -> String {
|
||||
let hex = |b: &[u8]| b.iter().map(|x| format!("{x:02x}")).collect::<String>();
|
||||
let mut out = format!("<{}00>\n", hex(&FONT[..20000]));
|
||||
for chunk in FONT[20000..].chunks(40000) {
|
||||
out.push_str(&format!("<{}>\n", hex(chunk)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A program defining Type 42 font `/T42` (codes 1 and 2 are H and I, by glyph names that
|
||||
/// don't say which characters they are) and composite font `/Comp` over it.
|
||||
fn fonts() -> String {
|
||||
let (h, _) = glyph('H');
|
||||
let (i, _) = glyph('I');
|
||||
format!(
|
||||
"10 dict begin /FontName /T42 def /FontType 42 def /FontMatrix [1 0 0 1 0 0] def /FontBBox [0 0 1 1] def /PaintType 0 def\n\
|
||||
/Encoding 256 array def 0 1 255 {{ Encoding exch /.notdef put }} for Encoding 1 /c01 put Encoding 2 /c02 put\n\
|
||||
/CharStrings 3 dict def CharStrings begin /.notdef 0 def /c01 {h} def /c02 {i} def end\n\
|
||||
/sfnts [\n{}] def FontName currentdict end definefont pop\n\
|
||||
16 dict begin /FontName /Comp def /FontType 0 def /FontMatrix [1 0 0 1 0 0] def /FMapType 2 def\n\
|
||||
/Encoding [0] def /FDepVector [/T42 findfont] def FontName currentdict end definefont pop\n",
|
||||
sfnts()
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type42_descendants_draw_their_own_glyphs() {
|
||||
let (_, wh) = glyph('H');
|
||||
let (_, wi) = glyph('I');
|
||||
let r = read(&format!("{}/Comp findfont 50 scalefont setfont 0 0 1 setrgbcolor 10 20 moveto (\\000\\001\\000\\002) show", fonts()));
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
let nodes = all(&r.document);
|
||||
assert!(!nodes.iter().any(|n| matches!(n.kind, NodeKind::Text(_))));
|
||||
let paths: Vec<_> = nodes.iter().filter(|n| matches!(n.kind, NodeKind::Path { .. })).collect();
|
||||
assert_eq!(paths.len(), 2);
|
||||
for p in &paths {
|
||||
assert_eq!(fill(p), Some(&Paint::solid(Color::rgb(0.0, 0.0, 1.0))));
|
||||
}
|
||||
// The H, then the I one H's width along; both stand on the baseline, a cap height tall.
|
||||
let (bh, bi) = (bounds(paths[0]), bounds(paths[1]));
|
||||
assert!((bh.x0 - 10.0).abs() < 8.0 && bh.width() > 20.0 && bh.width() < 50.0 * wh, "{bh:?}");
|
||||
assert!((bh.height() - bi.height()).abs() < 0.5 && bh.height() > 25.0 && bh.height() < 40.0, "{bh:?} {bi:?}");
|
||||
assert!(bi.x0 > 10.0 + 50.0 * wh - 1.0 && bi.x1 < 10.0 + 50.0 * (wh + wi), "{bi:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type42_descendants_measure_space_and_outline_their_glyphs() {
|
||||
let (_, wh) = glyph('H');
|
||||
let (_, wi) = glyph('I');
|
||||
let check = |cond: String| super::tests::check(&format!("{} /Comp findfont 50 scalefont setfont {cond}", fonts()));
|
||||
// The width its font gives each glyph.
|
||||
check(format!("(\\000\\001\\000\\002) stringwidth pop {} sub abs 0.01 lt", 50.0 * (wh + wi)));
|
||||
// xyshow places each character where its numbers say.
|
||||
check("gsave nulldevice 10 20 moveto (\\000\\001\\000\\002) [30 0 7 0] xyshow currentpoint grestore pop 47 sub abs 0.01 lt".into());
|
||||
// charpath outlines them.
|
||||
check("newpath 10 20 moveto (\\000\\001) true charpath pathbbox exch pop sub abs 25 gt exch 10 sub abs 8 lt and".into());
|
||||
}
|
||||
|
||||
/// A descendant that is a font by name only (its program isn't run) sets its characters as type,
|
||||
/// at the size the composite font's matrix gives them, not at 1000 times it.
|
||||
#[test]
|
||||
fn named_descendants_set_type_at_the_composite_fonts_size() {
|
||||
let r = read(
|
||||
"16 dict begin /FontName /Comp def /FontType 0 def /FontMatrix [1 0 0 1 0 0] def /FMapType 2 def\n\
|
||||
/Encoding [0] def /FDepVector [/Helvetica findfont] def FontName currentdict end definefont pop\n\
|
||||
/Comp findfont 30 scalefont setfont 10 20 moveto (\\000A\\000B) show",
|
||||
);
|
||||
let sizes: Vec<f64> = all(&r.document)
|
||||
.iter()
|
||||
.filter_map(|n| match &n.kind {
|
||||
NodeKind::Text(t) => Some(t.runs.iter().map(|r| r.style.size * t.xf.determinant().abs().sqrt()).sum::<f64>() / t.runs.len() as f64),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(!sizes.is_empty() && sizes.iter().all(|s| (s - 30.0).abs() < 0.5), "{sizes:?}");
|
||||
let bounds_all = all(&r.document).iter().map(|n| bounds(n)).reduce(|a, b| a.union(b)).unwrap();
|
||||
assert!(bounds_all.width() < 60.0, "{bounds_all:?}");
|
||||
}
|
||||
|
||||
/// `widthshow` adds its spacing to the composite character whose font number times 256 plus code
|
||||
/// is its `char` (PLRM 3rd ed., `widthshow` in chapter 8), not to every character with that code.
|
||||
#[test]
|
||||
fn widthshow_matches_a_composite_character_by_font_number_and_code() {
|
||||
let (_, wh) = glyph('H');
|
||||
let fonts = fonts().replace("/Encoding [0] def", "/Encoding [0 0] def");
|
||||
let check = |cond: String| super::tests::check(&format!("{fonts} /Comp findfont 50 scalefont setfont {cond}"));
|
||||
let end = |ch: u32| format!("gsave nulldevice 10 20 moveto 20 0 {ch} (\\000\\001\\001\\001) widthshow currentpoint grestore pop");
|
||||
// 257 is font 1's code 1: the second H only.
|
||||
check(format!("{} {} sub abs 0.01 lt", end(257), 10.0 + 100.0 * wh + 20.0));
|
||||
// 1 is font 0's code 1: the first H only.
|
||||
check(format!("{} {} sub abs 0.01 lt", end(1), 10.0 + 100.0 * wh + 20.0));
|
||||
// 2 is neither.
|
||||
check(format!("{} {} sub abs 0.01 lt", end(2), 10.0 + 100.0 * wh));
|
||||
}
|
||||
|
||||
/// Measuring a composite font's Type 42 glyphs (`stringwidth` on a long string in a `loop`)
|
||||
/// counts against the operation budget, as drawing them does.
|
||||
#[test]
|
||||
fn measuring_type42_descendants_in_a_loop_runs_out_of_the_budget() {
|
||||
super::tests::runs_out_of_budget(&format!(
|
||||
"{} /Comp findfont 50 scalefont setfont /s 100000 string def {{ s stringwidth pop pop }} loop",
|
||||
fonts()
|
||||
));
|
||||
}
|
||||
|
||||
/// A descendant whose TrueType data can't be read (an `sfnts` element that isn't a string, after
|
||||
/// 32 MB of strings) is read once, not again for every character measured: copying its strings
|
||||
/// costs about 130,000 operations, so 1,000 measures finish only if it is copied once (reading
|
||||
/// it each time would need 130 million, over the budget of 50 million).
|
||||
#[test]
|
||||
fn unreadable_type42_data_is_read_once() {
|
||||
super::tests::check(
|
||||
"10 dict begin /FontName /Bad def /FontType 42 def /FontMatrix [1 0 0 1 0 0] def /FontBBox [0 0 1 1] def /PaintType 0 def\n\
|
||||
/Encoding 256 array def 0 1 255 { Encoding exch /.notdef put } for\n\
|
||||
/CharStrings 1 dict def CharStrings /.notdef 0 put /sfnts [ 0 1 7 { pop 4000000 string } for 0 ] def FontName currentdict end definefont pop\n\
|
||||
16 dict begin /FontName /Comp def /FontType 0 def /FontMatrix [1 0 0 1 0 0] def /FMapType 2 def\n\
|
||||
/Encoding [0] def /FDepVector [/Bad findfont] def FontName currentdict end definefont pop\n\
|
||||
/Comp findfont 50 scalefont setfont 1000 { (\\000\\001) stringwidth pop pop } repeat true",
|
||||
);
|
||||
}
|
||||
|
||||
/// A Type 3 descendant draws and measures its glyphs with its own procedure, under the composite
|
||||
/// font's matrix: here 50 pt and twice as wide, so a glyph 500 × 700 units wide (600 units
|
||||
/// apart) is 50 × 35 pt, 60 pt apart (PLRM 3rd ed., §5.10: a descendant's glyphs are under the
|
||||
/// composite font's matrix as well as its own).
|
||||
#[test]
|
||||
fn type3_descendants_draw_and_measure_under_the_composite_matrix() {
|
||||
let fonts = "8 dict begin /FontName /T3 def /FontType 3 def /FontMatrix [0.001 0 0 0.001 0 0] def /FontBBox [0 0 1000 1000] def\n\
|
||||
/Encoding 256 array def 0 1 255 { Encoding exch /.notdef put } for Encoding 1 /box put\n\
|
||||
/BuildChar { exch begin 1 eq { 600 0 0 0 500 700 setcachedevice 0 0 500 700 rectfill } { 0 0 setcharwidth } ifelse end } def\n\
|
||||
FontName currentdict end definefont pop\n\
|
||||
16 dict begin /FontName /Comp def /FontType 0 def /FontMatrix [2 0 0 1 0 0] def /FMapType 2 def\n\
|
||||
/Encoding [0] def /FDepVector [/T3 findfont] def FontName currentdict end definefont pop\n\
|
||||
/Comp findfont 50 scalefont setfont";
|
||||
let r = read(&format!("{fonts} 0 0 1 setrgbcolor 10 20 moveto (\\000\\001\\000\\001) show"));
|
||||
let paths: Vec<_> = all(&r.document).into_iter().filter(|n| matches!(n.kind, NodeKind::Path { .. })).collect();
|
||||
assert_eq!(paths.len(), 2, "{:?}", r.warnings);
|
||||
let (a, b) = (bounds(&paths[0]), bounds(&paths[1]));
|
||||
for g in [a, b] {
|
||||
assert!((g.width() - 50.0).abs() < 0.01 && (g.height() - 35.0).abs() < 0.01, "{g:?}");
|
||||
}
|
||||
assert!((a.x0 - 10.0).abs() < 0.01 && (b.x0 - 70.0).abs() < 0.01, "{a:?} {b:?}");
|
||||
let check = |cond: &str| super::tests::check(&format!("{fonts} {cond}"));
|
||||
check("(\\000\\001\\000\\001) stringwidth pop 120 sub abs 0.01 lt");
|
||||
check("newpath 10 20 moveto (\\000\\001) true charpath currentpoint pop 70 sub abs 0.01 lt");
|
||||
}
|
||||
|
||||
/// A descendant found by name is scaled for each character it sets: copying a huge font
|
||||
/// dictionary for every character counts against the operation budget, so a `loop` of it stops.
|
||||
#[test]
|
||||
fn a_huge_named_descendant_in_a_loop_runs_out_of_the_budget() {
|
||||
super::tests::runs_out_of_budget(
|
||||
"200000 dict begin 0 1 199999 { dup def } for /FontName /Big def /FontType 1 def /FontMatrix [0.001 0 0 0.001 0 0] def\n\
|
||||
FontName currentdict end definefont pop\n\
|
||||
16 dict begin /FontName /Comp def /FontType 0 def /FontMatrix [1 0 0 1 0 0] def /FMapType 2 def\n\
|
||||
/Encoding [0] def /FDepVector [/Big findfont] def FontName currentdict end definefont pop\n\
|
||||
/Comp findfont 50 scalefont setfont { (\\000A) stringwidth pop pop } loop",
|
||||
);
|
||||
}
|
||||
|
||||
/// A composite font's TrueType data stays read while it is in use: the font a string uses for
|
||||
/// every other character isn't dropped (and read again, counting its size against the memory
|
||||
/// budget each time) while 16 other fonts are read between its uses.
|
||||
#[test]
|
||||
fn type42_data_in_use_stays_read() {
|
||||
let mut others = String::new();
|
||||
for k in 1..=16 {
|
||||
others.push_str(&format!(
|
||||
"10 dict begin /FontName /B{k} def /FontType 42 def /FontMatrix [1 0 0 1 0 0] def /FontBBox [0 0 1 1] def /PaintType 0 def\n\
|
||||
/Encoding 256 array def 0 1 255 {{ Encoding exch /.notdef put }} for\n\
|
||||
/CharStrings 1 dict def CharStrings /.notdef 0 put /sfnts [<00>] def FontName currentdict end definefont pop\n"
|
||||
));
|
||||
}
|
||||
let deps: String = (1..=16).map(|k| format!(" /B{k} findfont")).collect();
|
||||
let fonts = fonts().replace(
|
||||
"/Encoding [0] def /FDepVector [/T42 findfont] def",
|
||||
&format!("/Encoding [0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16] def /FDepVector [/T42 findfont{deps}] def"),
|
||||
);
|
||||
let s: String = (1..=16).map(|k| format!("\\000\\001\\{k:03o}\\001")).collect();
|
||||
super::tests::check(&format!("{others}{fonts} /Comp findfont 50 scalefont setfont 3000 {{ ({s}) stringwidth pop pop }} repeat true"));
|
||||
}
|
||||
|
||||
/// A page that cycles through more Type 42 fonts than are kept read reads each again when it
|
||||
/// comes back, but counts its data against the memory budget once.
|
||||
#[test]
|
||||
fn type42_data_read_again_counts_once() {
|
||||
let mut fonts = format!("/S [\n{}] def\n", sfnts());
|
||||
for k in 0..17 {
|
||||
fonts.push_str(&format!(
|
||||
"10 dict begin /FontName /F{k} def /FontType 42 def /FontMatrix [1 0 0 1 0 0] def /FontBBox [0 0 1 1] def /PaintType 0 def\n\
|
||||
/Encoding 256 array def 0 1 255 {{ Encoding exch /.notdef put }} for\n\
|
||||
/CharStrings 1 dict def CharStrings /.notdef 0 put /sfnts [ S aload pop ] def FontName currentdict end definefont pop\n"
|
||||
));
|
||||
}
|
||||
let deps: String = (0..17).map(|k| format!(" /F{k} findfont")).collect();
|
||||
let picks: String = (0..17).map(|k| format!(" {k}")).collect();
|
||||
fonts.push_str(&format!(
|
||||
"16 dict begin /FontName /Comp def /FontType 0 def /FontMatrix [1 0 0 1 0 0] def /FMapType 2 def\n\
|
||||
/Encoding [{picks}] def /FDepVector [{deps}] def FontName currentdict end definefont pop\n"
|
||||
));
|
||||
let s: String = (0..17).map(|k| format!("\\{k:03o}\\001")).collect();
|
||||
// 17 fonts of 431 kB, each read 200 times: 1.4 GB if each read counted.
|
||||
super::tests::check(&format!("{fonts} /Comp findfont 50 scalefont setfont 200 {{ ({s}) stringwidth pop pop }} repeat true"));
|
||||
}
|
||||
|
||||
/// Reading `sfnts` data that turns out unreadable is charged too: a composite font cycling
|
||||
/// through more such descendants than are kept read copies their strings on every glyph, and a
|
||||
/// `loop` of it runs out of the budget.
|
||||
#[test]
|
||||
fn unreadable_type42_data_read_again_runs_out_of_the_budget() {
|
||||
let mut fonts = String::from("/S [ 0 1 7 { pop 4000000 string } for ] def\n");
|
||||
for k in 0..17 {
|
||||
fonts.push_str(&format!(
|
||||
"10 dict begin /FontName /Bad{k} def /FontType 42 def /FontMatrix [1 0 0 1 0 0] def /FontBBox [0 0 1 1] def /PaintType 0 def\n\
|
||||
/Encoding 256 array def 0 1 255 {{ Encoding exch /.notdef put }} for\n\
|
||||
/CharStrings 1 dict def CharStrings /.notdef 0 put /sfnts [ S aload pop 0 ] def FontName currentdict end definefont pop\n"
|
||||
));
|
||||
}
|
||||
let deps: String = (0..17).map(|k| format!(" /Bad{k} findfont")).collect();
|
||||
let picks: String = (0..17).map(|k| format!(" {k}")).collect();
|
||||
let s: String = (0..17).map(|k| format!("\\{k:03o}\\001")).collect();
|
||||
super::tests::runs_out_of_budget(&format!(
|
||||
"{fonts}16 dict begin /FontName /Comp def /FontType 0 def /FontMatrix [1 0 0 1 0 0] def /FMapType 2 def\n\
|
||||
/Encoding [{picks}] def /FDepVector [{deps}] def FontName currentdict end definefont pop\n\
|
||||
/Comp findfont 50 scalefont setfont {{ ({s}) stringwidth pop pop }} loop"
|
||||
));
|
||||
}
|
||||
+273
-22
@@ -168,9 +168,13 @@ impl Interp<'_> {
|
||||
ResourceForAll => self.resource_for_all()?,
|
||||
Show | AShow | WidthShow | AWidthShow | XShow | YShow | XYShow | KShow => {
|
||||
let (s, spacing) = self.show_operands(op)?;
|
||||
match self.type3_font() {
|
||||
Some(font) => self.show_type3(&font, &s, &spacing)?,
|
||||
None => self.show(&s)?,
|
||||
if let Some(glyphs) = self.composite_glyphs(&s) {
|
||||
self.show_composite(&glyphs, &spacing)?;
|
||||
} else {
|
||||
match self.type3_font() {
|
||||
Some(font) => self.show_type3(&font, &s, &spacing)?,
|
||||
None => self.show(&s)?,
|
||||
}
|
||||
}
|
||||
}
|
||||
CShow => {
|
||||
@@ -273,6 +277,13 @@ impl Interp<'_> {
|
||||
|
||||
/// How far `s` moves the current point (user space).
|
||||
fn width_of(&mut self, s: &[u8]) -> Res<Vec2> {
|
||||
if let Some(glyphs) = self.composite_glyphs(s) {
|
||||
let mut w = Vec2::ZERO;
|
||||
for g in &glyphs {
|
||||
w += self.composite_glyph(g, Point::new(w.x, w.y), Draw::No)?;
|
||||
}
|
||||
return Ok(w);
|
||||
}
|
||||
let Some(font) = self.type3_font() else { return Ok(self.set_type(s, Point::ZERO)?.2) };
|
||||
let mut w = Vec2::ZERO;
|
||||
for &code in s {
|
||||
@@ -287,19 +298,7 @@ impl Interp<'_> {
|
||||
for (i, &code) in s.iter().enumerate() {
|
||||
let at = self.user_point()?;
|
||||
let w = self.type3_glyph(font, Glyph::Code(code), at, true)?;
|
||||
let d = match spacing {
|
||||
Spacing::Plain | Spacing::Kern(_) => w,
|
||||
Spacing::Add(a, c) => w + *a + c.filter(|(ch, _)| *ch == i64::from(code)).map_or(Vec2::ZERO, |(_, v)| v),
|
||||
Spacing::Widths(v, op) => {
|
||||
let n = |k: usize| v.get(k).copied();
|
||||
match op {
|
||||
Op::XShow => n(i).map(|x| Vec2::new(x, 0.0)),
|
||||
Op::YShow => n(i).map(|y| Vec2::new(0.0, y)),
|
||||
_ => n(2 * i).zip(n(2 * i + 1)).map(|(x, y)| Vec2::new(x, y)),
|
||||
}
|
||||
.unwrap_or(w)
|
||||
}
|
||||
};
|
||||
let d = spacing.advance(i, i64::from(code), w);
|
||||
self.g.cur = Some(self.xf() * (at + d));
|
||||
if let (Spacing::Kern(p), Some(next)) = (spacing, s.get(i + 1)) {
|
||||
self.push(Obj::Int(i64::from(code)))?;
|
||||
@@ -315,10 +314,15 @@ impl Interp<'_> {
|
||||
/// Glyph `glyph` of Type 3 font `font` drawn by its `BuildGlyph` (or `BuildChar`) procedure at
|
||||
/// user point `at` (only measured unless `paint`) → its width (user space).
|
||||
fn type3_glyph(&mut self, font: &DictRef, glyph: Glyph, at: Point, paint: bool) -> Res<Vec2> {
|
||||
self.type3_glyph_in(font, glyph, at, paint, Affine::IDENTITY)
|
||||
}
|
||||
|
||||
/// [`Self::type3_glyph`] for a descendant of a composite font whose matrix is `outer`.
|
||||
fn type3_glyph_in(&mut self, font: &DictRef, glyph: Glyph, at: Point, paint: bool, outer: Affine) -> Res<Vec2> {
|
||||
let (fm, build_glyph, build_char, encoding) = {
|
||||
let f = font.borrow();
|
||||
let get = |k: &str| f.get(&Key::name(k)).cloned();
|
||||
let fm = get("FontMatrix").and_then(|o| o.items().and_then(|i| matrix_of(&i.borrow()))).unwrap_or(Affine::scale(0.001));
|
||||
let fm = outer * font_matrix(&f, 0.001);
|
||||
(fm, get("BuildGlyph"), get("BuildChar"), get("Encoding"))
|
||||
};
|
||||
let code_of = |name: &str| -> Option<u8> {
|
||||
@@ -402,11 +406,16 @@ impl Interp<'_> {
|
||||
};
|
||||
let f = font.borrow();
|
||||
let name = f.get(&Key::name("FontName")).and_then(Obj::text).map_or_else(|| DEFAULT_FONT.to_string(), |n| n.to_string());
|
||||
let m = f.get(&Key::name("FontMatrix")).and_then(|o| o.items().and_then(|i| matrix_of(&i.borrow()))).unwrap_or(Affine::scale(0.001));
|
||||
// Type 1 glyphs are in 1000-unit space; a Type 42 font's glyph space is one unit an em
|
||||
// (its FontMatrix, usually the identity, is already the em's).
|
||||
let units = if f.get(&Key::name("FontType")).and_then(Obj::as_num) == Some(42.0) { 1.0 } else { 1000.0 };
|
||||
Ok((name, m * Affine::scale(units)))
|
||||
// A composite font's glyphs are its descendants', in their glyph space under its matrix
|
||||
// (PLRM 3rd ed., §5.10 "Composite Fonts"): the first descendant stands for them all.
|
||||
if font_type(&f) == Some(0.0) {
|
||||
let first = f.get(&Key::name("FDepVector")).and_then(|v| v.items()?.get(0));
|
||||
if let Some(Obj::Dict(d)) = first {
|
||||
let d = d.borrow();
|
||||
return Ok((name, font_matrix(&f, 1.0) * font_matrix(&d, 0.001) * Affine::scale(units(&d))));
|
||||
}
|
||||
}
|
||||
Ok((name, font_matrix(&f, 0.001) * Affine::scale(units(&f))))
|
||||
}
|
||||
|
||||
/// `s` (read as Latin-1) set in the current font at user point `at`: point type placed there,
|
||||
@@ -451,6 +460,14 @@ impl Interp<'_> {
|
||||
|
||||
/// `charpath`: the glyph outlines of `s` added to the path.
|
||||
fn char_path(&mut self, s: &[u8]) -> Res {
|
||||
if let Some(glyphs) = self.composite_glyphs(s) {
|
||||
for g in &glyphs {
|
||||
let at = self.user_point()?;
|
||||
let w = self.composite_glyph(g, at, Draw::Path)?;
|
||||
self.g.cur = Some(self.xf() * (at + w));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let at = self.user_point()?;
|
||||
let (t, layout, adv) = self.set_type(s, at)?;
|
||||
for g in &layout.glyphs {
|
||||
@@ -484,6 +501,240 @@ enum Glyph {
|
||||
Name(Rc<str>),
|
||||
}
|
||||
|
||||
impl Spacing {
|
||||
/// How far glyph `i` (character code `code`, width `w`) moves the current point.
|
||||
fn advance(&self, i: usize, code: i64, w: Vec2) -> Vec2 {
|
||||
match self {
|
||||
Self::Plain | Self::Kern(_) => w,
|
||||
Self::Add(a, c) => w + *a + c.filter(|(ch, _)| *ch == code).map_or(Vec2::ZERO, |(_, v)| v),
|
||||
Self::Widths(v, op) => {
|
||||
let n = |k: usize| v.get(k).copied();
|
||||
match op {
|
||||
Op::XShow => n(i).map(|x| Vec2::new(x, 0.0)),
|
||||
Op::YShow => n(i).map(|y| Vec2::new(0.0, y)),
|
||||
_ => n(2 * i).zip(n(2 * i + 1)).map(|(x, y)| Vec2::new(x, y)),
|
||||
}
|
||||
.unwrap_or(w)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A character of a composite font: the descendant font that draws it, its code there, and the
|
||||
/// composite font's matrix (over the descendant's own).
|
||||
struct Mapped {
|
||||
font: DictRef,
|
||||
/// The byte that picked the descendant (the font number).
|
||||
pick: u8,
|
||||
code: u8,
|
||||
outer: Affine,
|
||||
}
|
||||
|
||||
/// What drawing a glyph does with it.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Draw {
|
||||
/// Fills it (`show`).
|
||||
Fill,
|
||||
/// Adds its outline to the current path (`charpath`).
|
||||
Path,
|
||||
/// Only measures it (`stringwidth`).
|
||||
No,
|
||||
}
|
||||
|
||||
/// Font `f`'s `FontType`.
|
||||
fn font_type(f: &Dict) -> Option<f64> {
|
||||
f.get(&Key::name("FontType")).and_then(Obj::as_num)
|
||||
}
|
||||
|
||||
/// Font `f`'s matrix, `default` times the identity when it has none.
|
||||
fn font_matrix(f: &Dict, default: f64) -> Affine {
|
||||
f.get(&Key::name("FontMatrix")).and_then(|o| o.items().and_then(|i| matrix_of(&i.borrow()))).unwrap_or(Affine::scale(default))
|
||||
}
|
||||
|
||||
/// Font `f`'s glyph units in its matrix's space: Type 1 glyphs are in 1000-unit space; a Type 42
|
||||
/// font's glyph space is one unit an em (its FontMatrix, usually the identity, is already the
|
||||
/// em's).
|
||||
fn units(f: &Dict) -> f64 {
|
||||
if font_type(f) == Some(42.0) { 1.0 } else { 1000.0 }
|
||||
}
|
||||
|
||||
impl Interp<'_> {
|
||||
/// The characters of `s` in the current font when it is a composite font with the 8/8 mapping
|
||||
/// (`FMapType` 2: a byte picking the descendant through `Encoding` and `FDepVector`, then the
|
||||
/// code in it: PLRM 3rd ed., §5.10.1 "Type 0 Font Dictionaries"); `None` for other fonts and
|
||||
/// mappings, which are set as type in the font of the composite font's name.
|
||||
fn composite_glyphs(&self, s: &[u8]) -> Option<Vec<Mapped>> {
|
||||
let font = self.g.font.clone()?;
|
||||
let f = font.borrow();
|
||||
if font_type(&f) != Some(0.0) || f.get(&Key::name("FMapType")).and_then(Obj::as_num) != Some(2.0) {
|
||||
return None;
|
||||
}
|
||||
let (encoding, fonts) = (f.get(&Key::name("Encoding"))?.items()?.clone(), f.get(&Key::name("FDepVector"))?.items()?.clone());
|
||||
let (encoding, fonts) = (encoding.borrow(), fonts.borrow());
|
||||
let outer = font_matrix(&f, 1.0);
|
||||
let mut out = Vec::with_capacity(s.len() / 2);
|
||||
for &[pick, code] in s.as_chunks::<2>().0 {
|
||||
let index = encoding.get(usize::from(pick)).and_then(Obj::as_num).filter(|i| *i >= 0.0 && i.fract() == 0.0);
|
||||
// A character whose descendant is missing (or not a base font) shows nothing.
|
||||
if let Some(Obj::Dict(d)) = index.and_then(|i| fonts.get(i as usize))
|
||||
&& font_type(&d.borrow()) != Some(0.0)
|
||||
{
|
||||
out.push(Mapped { font: d.clone(), pick, code, outer });
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The characters of a composite font shown at the current point, spaced as `spacing` says.
|
||||
fn show_composite(&mut self, glyphs: &[Mapped], spacing: &Spacing) -> Res {
|
||||
let start = self.out.drawn.len();
|
||||
let mut text = String::new();
|
||||
for (i, g) in glyphs.iter().enumerate() {
|
||||
let at = self.user_point()?;
|
||||
let w = self.composite_glyph(g, at, Draw::Fill)?;
|
||||
// `widthshow` and `awidthshow` match a composite font's character by its font number
|
||||
// times 256 plus its code (PLRM 3rd ed., `widthshow` in chapter 8).
|
||||
let d = spacing.advance(i, i64::from(g.pick) * 256 + i64::from(g.code), w);
|
||||
self.g.cur = Some(self.xf() * (at + d));
|
||||
let c = code_char(&g.font.borrow(), g.code);
|
||||
if c != '\u{2022}' {
|
||||
text.push(c);
|
||||
}
|
||||
}
|
||||
if text.trim().is_empty()
|
||||
&& let Some(name) = self.g.font.as_ref().and_then(|f| f.borrow().get(&Key::name("FontName")).and_then(Obj::text))
|
||||
{
|
||||
text = name.to_string();
|
||||
}
|
||||
self.group_glyphs(start, &text);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Character `g` of a composite font at user point `at`, drawn by its descendant: a Type 3
|
||||
/// font's procedure, a Type 42 font's TrueType outline, else as type in the font of the
|
||||
/// descendant's name (the character its glyph name stands for) → its width (user space).
|
||||
fn composite_glyph(&mut self, g: &Mapped, at: Point, paint: Draw) -> Res<Vec2> {
|
||||
let builds = {
|
||||
let f = g.font.borrow();
|
||||
font_type(&f) == Some(3.0) && (f.contains_key(&Key::name("BuildGlyph")) || f.contains_key(&Key::name("BuildChar")))
|
||||
};
|
||||
if builds {
|
||||
return self.type3_glyph_in(&g.font, Glyph::Code(g.code), at, paint == Draw::Fill, g.outer);
|
||||
}
|
||||
let fm = g.outer * font_matrix(&g.font.borrow(), 1.0);
|
||||
if let Some((outline, advance)) = self.type42_glyph(&g.font, g.code)? {
|
||||
// Measuring a glyph reads its outline too, so `stringwidth` in a loop pays for it.
|
||||
self.spend((outline.elements().len() as u64).saturating_add(16))?;
|
||||
if paint != Draw::No {
|
||||
let mut p = outline;
|
||||
p.apply_affine(self.xf() * Affine::translate(at.to_vec2()) * fm);
|
||||
if paint == Draw::Fill {
|
||||
self.fill_path(p, vectorcraft_geom::FillRule::NonZero)?;
|
||||
} else {
|
||||
if self.g.path.elements().len().saturating_add(p.elements().len()) > super::graphics::MAX_PATH {
|
||||
return Err(PsError::Limit("a path has too many points"));
|
||||
}
|
||||
self.g.path.extend(p);
|
||||
}
|
||||
}
|
||||
return Ok(fm * Point::new(advance, 0.0) - fm * Point::ZERO);
|
||||
}
|
||||
// Set as type in the descendant's font, under the composite font's matrix.
|
||||
let c = code_char(&g.font.borrow(), g.code);
|
||||
let Ok(byte) = u8::try_from(u32::from(c)) else { return Ok(Vec2::ZERO) };
|
||||
// Scaling the descendant copies its dictionary for every character: charged by its size,
|
||||
// so a huge font dictionary shown in a loop runs out of the budget.
|
||||
let entries = g.font.borrow().len();
|
||||
self.spend(entries as u64)?;
|
||||
let saved = self.g.font.replace(transformed(&g.font, g.outer));
|
||||
let r = match paint {
|
||||
Draw::Fill => {
|
||||
self.g.cur = Some(self.xf() * at);
|
||||
self.show(&[byte]).and_then(|()| self.user_point()).map(|p| p - at)
|
||||
}
|
||||
Draw::Path => {
|
||||
self.g.cur = Some(self.xf() * at);
|
||||
self.char_path(&[byte]).and_then(|()| self.user_point()).map(|p| p - at)
|
||||
}
|
||||
Draw::No => self.set_type(&[byte], at).map(|t| t.2),
|
||||
};
|
||||
self.g.font = saved;
|
||||
r
|
||||
}
|
||||
|
||||
/// Glyph `code` of Type 42 font `font` from the TrueType font it carries: its outline and
|
||||
/// width in its glyph space (ems), or `None` when it isn't a Type 42 font with one.
|
||||
fn type42_glyph(&mut self, font: &DictRef, code: u8) -> Res<Option<(vectorcraft_geom::BezPath, f64)>> {
|
||||
let (sfnts, gid) = {
|
||||
let f = font.borrow();
|
||||
if font_type(&f) != Some(42.0) {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(Obj::Array { items: sfnts, .. }) = f.get(&Key::name("sfnts")).cloned() else { return Ok(None) };
|
||||
let name = glyph_name(&f, code);
|
||||
let strings = f.get(&Key::name("CharStrings")).and_then(|c| match c {
|
||||
Obj::Dict(d) => Some(d.clone()),
|
||||
_ => None,
|
||||
});
|
||||
let gid = strings
|
||||
.and_then(|d| {
|
||||
let d = d.borrow();
|
||||
d.get(&Key::name(&name)).or_else(|| d.get(&Key::name(".notdef"))).and_then(Obj::as_num)
|
||||
})
|
||||
.filter(|g| (0.0..=f64::from(u16::MAX)).contains(g))
|
||||
.map_or(0, |g| g as u32);
|
||||
(sfnts, gid)
|
||||
};
|
||||
// The least recently used font goes first: a hit moves to the end.
|
||||
let data = match self.type42.iter().position(|(k, _)| k.same(&sfnts)) {
|
||||
Some(i) => {
|
||||
let hit = self.type42.remove(i);
|
||||
let data = hit.1.clone();
|
||||
self.type42.push(hit);
|
||||
data
|
||||
}
|
||||
None => {
|
||||
// Joining the strings visits each element and copies its bytes, whether or not
|
||||
// the font can then be read: charged first, as other bulk copies are.
|
||||
let (elements, bytes) = super::type42::font_size(&sfnts.borrow());
|
||||
self.spend_bytes(bytes)?;
|
||||
self.spend((elements / 16) as u64)?;
|
||||
// Fonts whose data can't be read are kept too, so they are copied only once.
|
||||
let data = super::type42::font_data(&sfnts.borrow()).map(Rc::new);
|
||||
if let Some(d) = &data
|
||||
&& self.type42_counted.insert(sfnts.clone())
|
||||
{
|
||||
self.alloc(d.len())?;
|
||||
}
|
||||
if self.type42.len() >= MAX_TYPE42 {
|
||||
self.type42.remove(0);
|
||||
}
|
||||
self.type42.push((sfnts, data.clone()));
|
||||
data
|
||||
}
|
||||
};
|
||||
let Some(data) = data else { return Ok(None) };
|
||||
Ok(super::type42::glyph(&data, gid))
|
||||
}
|
||||
}
|
||||
|
||||
/// Most Type 42 fonts whose TrueType data is kept between glyphs.
|
||||
const MAX_TYPE42: usize = 16;
|
||||
|
||||
/// The character code `code` of font `f` stands for: its glyph name's, or the code read as
|
||||
/// Latin-1 when the font names no glyph for it (a font found by name, whose program isn't run).
|
||||
fn code_char(f: &Dict, code: u8) -> char {
|
||||
match &*glyph_name(f, code) {
|
||||
".notdef" => char::from(code),
|
||||
name => glyph_char(name),
|
||||
}
|
||||
}
|
||||
|
||||
/// The glyph name font `f`'s `Encoding` gives code `code` (`.notdef` without one).
|
||||
fn glyph_name(f: &Dict, code: u8) -> Rc<str> {
|
||||
f.get(&Key::name("Encoding")).and_then(|e| e.items()?.get(usize::from(code))).and_then(|o| o.text()).unwrap_or_else(|| Rc::from(".notdef"))
|
||||
}
|
||||
|
||||
/// A font scaled or transformed by `m` (`scalefont`, `makefont`).
|
||||
fn transformed(font: &DictRef, m: Affine) -> DictRef {
|
||||
let mut d = font.borrow().clone();
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
//! Type 42 fonts' glyphs drawn from the TrueType font they carry, for the type a composite font
|
||||
//! shows: its glyphs are picked by glyph index, often in a subset font without a character map,
|
||||
//! so they can't be set again as type in an installed font. The font is the `sfnts` strings
|
||||
//! joined, each string of odd length ending in a padding byte that isn't part of it (Adobe
|
||||
//! Technical Note #5012, "The Type 42 Font Format Specification", on the `sfnts` array); a glyph
|
||||
//! name maps to its glyph index through `CharStrings`.
|
||||
|
||||
use skrifa::instance::{LocationRef, Size};
|
||||
use skrifa::outline::{DrawSettings, OutlinePen};
|
||||
use skrifa::{FontRef, GlyphId, MetadataProvider};
|
||||
use vectorcraft_geom::BezPath;
|
||||
|
||||
use super::obj::Obj;
|
||||
|
||||
/// Most bytes of TrueType data a Type 42 font may carry.
|
||||
pub(super) const MAX_FONT: usize = 32 << 20;
|
||||
|
||||
/// The TrueType font of a Type 42 font's `sfnts` strings, or `None` when an element isn't a
|
||||
/// string or the font would be larger than [`MAX_FONT`].
|
||||
pub(super) fn font_data(sfnts: &[Obj]) -> Option<Vec<u8>> {
|
||||
let mut data = Vec::new();
|
||||
for s in sfnts {
|
||||
let Obj::Str(s) = s else { return None };
|
||||
let s = s.borrow();
|
||||
let len = s.len() - s.len() % 2;
|
||||
if data.len() + len > MAX_FONT {
|
||||
return None;
|
||||
}
|
||||
data.extend_from_slice(s.get(..len).unwrap_or_default());
|
||||
}
|
||||
Some(data)
|
||||
}
|
||||
|
||||
/// What [`font_data`] visits for `sfnts`: the elements up to the first that isn't a string, and
|
||||
/// the bytes of those strings (at most [`MAX_FONT`] and one string more).
|
||||
pub(super) fn font_size(sfnts: &[Obj]) -> (usize, usize) {
|
||||
let mut bytes = 0usize;
|
||||
for (i, s) in sfnts.iter().enumerate() {
|
||||
let Obj::Str(s) = s else { return (i + 1, bytes) };
|
||||
bytes = bytes.saturating_add(s.borrow().len());
|
||||
if bytes > MAX_FONT {
|
||||
return (i + 1, bytes.min(2 * MAX_FONT));
|
||||
}
|
||||
}
|
||||
(sfnts.len(), bytes)
|
||||
}
|
||||
|
||||
/// Glyph `gid` of TrueType font `data`: its outline and advance width, in ems (y up), or `None`
|
||||
/// when the font can't be read.
|
||||
pub(super) fn glyph(data: &[u8], gid: u32) -> Option<(BezPath, f64)> {
|
||||
let font = FontRef::new(data).ok()?;
|
||||
let upem = f64::from(font.metrics(Size::unscaled(), LocationRef::default()).units_per_em);
|
||||
if upem <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let id = GlyphId::new(gid);
|
||||
let mut pen = Pen(BezPath::new(), 1.0 / upem);
|
||||
if gid != 0
|
||||
&& let Some(g) = font.outline_glyphs().get(id)
|
||||
{
|
||||
// A glyph that can't be drawn draws nothing; its width still moves the next one.
|
||||
let _ = g.draw(DrawSettings::unhinted(Size::unscaled(), LocationRef::default()), &mut pen);
|
||||
}
|
||||
let advance = font.glyph_metrics(Size::unscaled(), LocationRef::default()).advance_width(id).map_or(0.0, f64::from) / upem;
|
||||
Some((pen.0, if advance.is_finite() { advance } else { 0.0 }))
|
||||
}
|
||||
|
||||
/// A pen drawing an outline scaled from font units to ems.
|
||||
struct Pen(BezPath, f64);
|
||||
|
||||
impl OutlinePen for Pen {
|
||||
fn move_to(&mut self, x: f32, y: f32) {
|
||||
self.0.move_to((f64::from(x) * self.1, f64::from(y) * self.1));
|
||||
}
|
||||
fn line_to(&mut self, x: f32, y: f32) {
|
||||
self.0.line_to((f64::from(x) * self.1, f64::from(y) * self.1));
|
||||
}
|
||||
fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) {
|
||||
let s = self.1;
|
||||
self.0.quad_to((f64::from(cx0) * s, f64::from(cy0) * s), (f64::from(x) * s, f64::from(y) * s));
|
||||
}
|
||||
fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) {
|
||||
let s = self.1;
|
||||
self.0.curve_to((f64::from(cx0) * s, f64::from(cy0) * s), (f64::from(cx1) * s, f64::from(cy1) * s), (f64::from(x) * s, f64::from(y) * s));
|
||||
}
|
||||
fn close(&mut self) {
|
||||
self.0.close_path();
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,27 @@ pub fn split_into_grid(rect: Rect, rows: usize, cols: usize, gutter: f64) -> Vec
|
||||
|
||||
`simplify` fits least-squares cubics with fixed end tangents, then reparameterizes with Newton steps and splits at the point of worst error. Any anchor that turns more than the corner threshold (30° by default) stays a corner.
|
||||
|
||||
## Limits
|
||||
|
||||
Every boolean, Pathfinder operation, region map and normalization goes through one sweep. The `try_` functions
|
||||
(`try_boolean`, `try_normalize`, `try_unite_all`, …) refuse input it can't sweep with a [`PathOpsError`](src/lib.rs)
|
||||
rather than answering with part of the art missing. The others have no error to return: `boolean`, `normalize` and
|
||||
`area` answer an empty path or 0, and `unite_all` leaves a refused input out of the union, or answers an empty path
|
||||
when the inputs together are refused.
|
||||
|
||||
- `NonFinite`: a coordinate is NaN or infinite.
|
||||
- `Degenerate`: the coordinates are so large that the sweep's tolerance would pass one unit (`MAX_SWEEP_EPS`), so
|
||||
features thinner than it would collapse. A panic inside the sweep on rare near-degenerate input is caught and
|
||||
reported as `Degenerate` too (on native builds; on the web a panic aborts).
|
||||
- `OpenPath`: the sweep could not form closed contours from what it was given (open subpaths are closed before it).
|
||||
- `TooComplex`: more than [`MAX_SEGMENT_PAIRS`](src/budget.rs) pairs of segments have overlapping bounds. They
|
||||
are counted exactly before the sweep, in O(n log n). This bounds what the sweep is given, not how many times the
|
||||
segments cross: a compact path that crosses itself many times can stay under it and still take seconds.
|
||||
|
||||
Results are refitted within the precision (`DEFAULT_PRECISION`, unless `try_boolean`, `unite_all_with`,
|
||||
`try_unite_all_with` or `pathfinder_with` is given one) and contours thinner than it on average are dropped as slivers
|
||||
(see Booleans above).
|
||||
|
||||
## Caveats
|
||||
|
||||
- linesweeper is still in beta. When two identical curves overlap, the sweep can leave a tiny lens between them; the sliver filter removes it.
|
||||
|
||||
@@ -222,7 +222,16 @@ impl Record {
|
||||
extra.push(ascii.len() as u8);
|
||||
extra.extend(ascii);
|
||||
extra.resize(extra.len().next_multiple_of(4), 0);
|
||||
let units: Vec<u16> = self.name.encode_utf16().take(255).collect();
|
||||
// Cut before the character that would pass 255 units, never inside a surrogate pair.
|
||||
let mut units: Vec<u16> = vec![];
|
||||
for c in self.name.chars() {
|
||||
let mut buf = [0; 2];
|
||||
let encoded = c.encode_utf16(&mut buf);
|
||||
if units.len() + encoded.len() > 255 {
|
||||
break;
|
||||
}
|
||||
units.extend_from_slice(encoded);
|
||||
}
|
||||
let mut luni = (units.len() as u32).to_be_bytes().to_vec();
|
||||
luni.extend(units.iter().flat_map(|u| u.to_be_bytes()));
|
||||
block(&mut extra, b"luni", &luni)?;
|
||||
|
||||
@@ -321,3 +321,12 @@ fn the_profile_and_the_size_limit() {
|
||||
let err = Renderer::new().export_region(&doc, doc.artboards[0].rect, RasterFormat::Psd, &o).unwrap_err();
|
||||
assert!(err.contains(&MAX_SIDE.to_string()), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_unicode_name_is_cut_on_a_character_boundary() {
|
||||
let mut b = DocBuilder::new(100.0, 60.0);
|
||||
b.layer(&("a".repeat(254) + "😀"));
|
||||
b.rect(Rect::new(0.0, 0.0, 5.0, 5.0), Color::rgb8(0, 0, 0), |_| {});
|
||||
let file = export(&b.build(), &opts(PsdOptions::default()));
|
||||
assert_eq!(parse(&file).records.last().unwrap().name, "a".repeat(254));
|
||||
}
|
||||
|
||||
@@ -18,7 +18,8 @@ pub(super) type XNode<'a, 'i> = roxmltree::Node<'a, 'i>;
|
||||
/// `name: value` declarations of a block or `style` attribute, with their `!important` flag. The
|
||||
/// `font` shorthand becomes its longhands, as usvg reads it for the rest of the drawing (#1557).
|
||||
pub(super) fn parse_decls(s: &str) -> Vec<(String, String, bool)> {
|
||||
split_decls(s)
|
||||
let s = strip_comments(s);
|
||||
split_decls(&s)
|
||||
.into_iter()
|
||||
.filter_map(|d| {
|
||||
let (k, v) = d.split_once(':')?;
|
||||
@@ -354,9 +355,8 @@ fn top_level_commas(list: &str) -> impl Iterator<Item = &str> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Rules from every `<style>` element, in source order.
|
||||
fn parse_sheet(css: &str, out: &mut Vec<Rule>) {
|
||||
// Drop comments outside strings.
|
||||
/// `css` without its `/* … */` comments (those inside quoted strings stay).
|
||||
fn strip_comments(css: &str) -> String {
|
||||
let mut text = String::with_capacity(css.len());
|
||||
let mut chars = css.chars().peekable();
|
||||
let mut quote = None;
|
||||
@@ -387,6 +387,12 @@ fn parse_sheet(css: &str, out: &mut Vec<Rule>) {
|
||||
text.push(c);
|
||||
}
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
/// Rules from every `<style>` element, in source order.
|
||||
fn parse_sheet(css: &str, out: &mut Vec<Rule>) {
|
||||
let text = strip_comments(css);
|
||||
let mut s = text.as_str();
|
||||
loop {
|
||||
s = s.trim_start();
|
||||
|
||||
@@ -333,3 +333,11 @@ fn not_selectors() {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decl_comments_are_stripped() {
|
||||
let d = |k: &str, v: &str| (k.to_string(), v.to_string(), false);
|
||||
assert_eq!(parse_decls("fill:red /* note */; stroke:blue"), [d("fill", "red"), d("stroke", "blue")]);
|
||||
assert_eq!(parse_decls("fill:red /* a;b */ ; stroke:blue"), [d("fill", "red"), d("stroke", "blue")]);
|
||||
assert_eq!(parse_decls("font-family:'A /* x */ B'"), [d("font-family", "'A /* x */ B'")]);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::rings::{MAX_PIECES, MAX_PLANE_POINTS, MAX_SOLID_POINTS, inset, left, rings, signed_area};
|
||||
use super::rings::{MAX_PIECES, MAX_PLANE_POINTS, MAX_SOLID_POINTS, inset, left, signed_area, solid_rings};
|
||||
use super::{Bevel, Extrude, Facet, FillRule, MAX_FACES, PathData, Point, Rect, Revolve, V3, View, eye_distance, perspective_scale};
|
||||
|
||||
/// Turns 3D points into document points and shades faces for one view.
|
||||
@@ -403,7 +403,7 @@ pub(crate) fn extrude_faces(path: &PathData, rule: FillRule, bounds: Rect, optio
|
||||
if o.depth <= 0.0 {
|
||||
return plane_faces(path, rule, bounds, view, mesh);
|
||||
}
|
||||
let rings = rings(path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?;
|
||||
let rings = solid_rings(path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?;
|
||||
let camera = Camera::new(view, bounds, o.depth);
|
||||
let mut b = Builder { camera: &camera, closed: o.cap, facets: vec![], mesh: mesh.then(Vec::new) };
|
||||
let steps = steps(&o);
|
||||
@@ -461,7 +461,7 @@ pub fn plane(path: &PathData, rule: FillRule, bounds: Rect, view: View) -> Resul
|
||||
/// [`plane`], or with `mesh` its face before projection instead of its facet.
|
||||
pub(crate) fn plane_faces(path: &PathData, rule: FillRule, bounds: Rect, view: View, mesh: bool) -> Result<Built, String> {
|
||||
check_bounds(bounds)?;
|
||||
let rings = rings(path, rule, bounds.width().max(bounds.height()), MAX_PLANE_POINTS)?;
|
||||
let rings = solid_rings(path, rule, bounds.width().max(bounds.height()), MAX_PLANE_POINTS)?;
|
||||
let camera = Camera::new(view, bounds, 0.0);
|
||||
let mut b = Builder { camera: &camera, closed: false, facets: vec![], mesh: mesh.then(Vec::new) };
|
||||
let contours: Vec<Vec<V3>> = rings.iter().map(|r| r.iter().map(|p| camera.place(*p, 0.0)).collect()).collect();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::build::{Builder, Built, Camera, Order, Turn, check_bounds, cuts, fit, newell, outward};
|
||||
use super::membrane::{Edges, MAX_MATERIAL_SEGMENTS, Membrane, cell_triangles, clip, filled, material};
|
||||
use super::rings::{MAX_SOLID_POINTS, rings, signed_area};
|
||||
use super::rings::{MAX_SOLID_POINTS, signed_area, solid_rings};
|
||||
use super::{Facet, FillRule, Inflate, MAX_FACES, PathData, Point, Rect, V3, Vec2, View};
|
||||
|
||||
/// A shape to inflate: its rings and the membrane under them on the grid of the bounds.
|
||||
@@ -18,7 +18,7 @@ struct Puff {
|
||||
|
||||
/// The rings of `path` and its membrane on the grid of `bounds`; `None` for a shape with no area.
|
||||
fn puff(path: &PathData, rule: FillRule, bounds: Rect, o: &Inflate) -> Result<Option<Puff>, String> {
|
||||
let rings = rings(path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?;
|
||||
let rings = solid_rings(path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?;
|
||||
if rings.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,175 @@ fn pieces(c: vectorcraft_geom::CubicBez, tolerance: f64) -> u32 {
|
||||
if n.is_finite() { (n as u32).clamp(1, 32) } else { 32 }
|
||||
}
|
||||
|
||||
/// Contours thinner than this on average (twice their area over their perimeter) are dropped when
|
||||
/// overlaps are resolved: the debris a boolean leaves where two curves coincide, far below any
|
||||
/// art. Pathfinder's default precision would drop real hairlines too.
|
||||
pub(super) const DEBRIS: f64 = 1e-6;
|
||||
|
||||
/// The rings of a solid's profile, as [`rings`], with contours that meet merged first. Type often
|
||||
/// draws one glyph as overlapping contours (a "k"'s arm across its stem), and [`rings`] tells a
|
||||
/// contour's side from one point of it, so a contour that meets another, or itself, must be cut
|
||||
/// where they meet. Most art has no such contours, and the boolean costs more than the rest of the
|
||||
/// solid, so it runs only when [`edges_meet`] finds some. Its answer already runs holes against
|
||||
/// their outlines, so it is only turned as a whole ([`wound`]), never judged a ring at a time.
|
||||
///
|
||||
/// The `limit` is checked on the profile as drawn, before the boolean, and again on the merged
|
||||
/// outline, which can have more points (bars crossing in a grid trace every hole between them):
|
||||
/// past it the shape is too complex, since its contours as drawn cross and can't be built as they
|
||||
/// are. A profile the boolean refuses is built from its contours as drawn, as it was before
|
||||
/// contours were merged. An empty answer is an answer: contours that cancel (a square drawn twice
|
||||
/// under even-odd), or that are all thinner than [`DEBRIS`], fill nothing, so nothing is built.
|
||||
pub(super) fn solid_rings(path: &PathData, rule: FillRule, size: f64, limit: usize) -> Result<Vec<Vec<Point>>, String> {
|
||||
let sampled = sample(path, size, limit)?;
|
||||
let closed = closed_straight(path);
|
||||
if !meets(&sampled, &closed) {
|
||||
return orient(sampled, rule);
|
||||
}
|
||||
match vectorcraft_pathops::try_unite_all_with(&[(&closed, rule)], DEBRIS) {
|
||||
Ok(merged) => sample(&merged, size, limit).map(wound),
|
||||
Err(_) => orient(sampled, rule),
|
||||
}
|
||||
}
|
||||
|
||||
/// Do the `sampled` rings of `closed` (the profile as the boolean takes it) meet, so that it must
|
||||
/// go through the boolean? Within twice the boolean's tolerance ([`touch`]): pathops first snaps
|
||||
/// anchors onto nearby heights (by less than the tolerance), then the sweep joins edges within it
|
||||
/// along the sweep line, so straight edges a little more than it apart can come out as one: at
|
||||
/// most sin θ + cos θ ≤ √2 times it, θ the edges' angle to the sweep line (about 1.4 at 45°,
|
||||
/// measured; the same when pathops turns the sweep a quarter turn, which it does for some art of
|
||||
/// 2,048 segments or more). Straight contours it might join are merged; those it leaves apart cost
|
||||
/// a boolean that builds the same shape. Curves are judged on their sampled outline (see
|
||||
/// [`sample`]), which can lie a little inside them (up to the sampling tolerance, or more for a
|
||||
/// long curve held to 32 pieces), so curves that touch or overlap by less than that can be judged
|
||||
/// apart and built as drawn.
|
||||
pub(super) fn meets(sampled: &[Vec<Point>], closed: &PathData) -> bool {
|
||||
edges_meet(sampled, 2.0 * touch(closed))
|
||||
}
|
||||
|
||||
/// `path` as the boolean should take it: each open subpath closed by a straight line, as [`sample`]
|
||||
/// and the renderer's fill close it (the boolean would close it along the end anchors' handles).
|
||||
/// Borrowed when nothing needs changing: every subpath closed, a single anchor (which the boolean
|
||||
/// skips), or open with no handle on the closing side of either end anchor (which the boolean
|
||||
/// already closes straight).
|
||||
pub(super) fn closed_straight(path: &PathData) -> std::borrow::Cow<'_, PathData> {
|
||||
// The closing segment (last anchor to first) as the boolean will draw it once closed; the
|
||||
// length test comes first, so a subpath of fewer than two anchors never reaches the index.
|
||||
let straight = |sp: &vectorcraft_geom::SubPath| sp.closed || sp.anchors.len() < 2 || sp.segment_is_line(sp.anchors.len() - 1);
|
||||
if path.subpaths.iter().all(straight) {
|
||||
return std::borrow::Cow::Borrowed(path);
|
||||
}
|
||||
let mut path = path.clone();
|
||||
for sp in path.subpaths.iter_mut().filter(|sp| !sp.closed) {
|
||||
if let Some(last) = sp.anchors.last_mut() {
|
||||
last.h_out = last.p;
|
||||
}
|
||||
if let Some(first) = sp.anchors.first_mut() {
|
||||
first.h_in = first.p;
|
||||
}
|
||||
sp.closed = true;
|
||||
}
|
||||
std::borrow::Cow::Owned(path)
|
||||
}
|
||||
|
||||
/// The boolean's rings turned so the filled side is on each one's left. Its holes already run
|
||||
/// against their outlines, so only the direction of the whole is chosen: the one whose rings
|
||||
/// enclose positive area in all.
|
||||
pub(super) fn wound(mut rings: Vec<Vec<Point>>) -> Vec<Vec<Point>> {
|
||||
rings.retain(|r| signed_area(r).abs() > 1e-9);
|
||||
if rings.iter().map(|r| signed_area(r)).sum::<f64>() < 0.0 {
|
||||
rings.iter_mut().for_each(|r| r.reverse());
|
||||
}
|
||||
rings
|
||||
}
|
||||
|
||||
/// The boolean's sweep tolerance for `path` as it is given to it (closed by [`closed_straight`]):
|
||||
/// 64 times the rounding of the largest coordinate it reads, every anchor and handle of each
|
||||
/// subpath of two anchors or more, and at least 1e-6 (pathops' `eps_for`). For a path [`sample`]
|
||||
/// accepts (every point finite).
|
||||
pub(super) fn touch(path: &PathData) -> f64 {
|
||||
let m = path
|
||||
.subpaths
|
||||
.iter()
|
||||
.filter(|sp| sp.anchors.len() >= 2)
|
||||
.flat_map(|sp| &sp.anchors)
|
||||
.flat_map(|a| [a.p, a.h_in, a.h_out])
|
||||
.fold(0.0f64, |m, q| m.max(q.x.abs()).max(q.y.abs()));
|
||||
(m * f64::EPSILON * 64.0).max(1e-6)
|
||||
}
|
||||
|
||||
/// Most pairs of edges [`edges_meet`] compares. Past it the answer is yes, and the boolean, with
|
||||
/// its own budget, decides.
|
||||
const MAX_EDGE_PAIRS: usize = 1 << 22;
|
||||
|
||||
/// Do any two edges of `rings` meet, within `touch` (at that distance or closer): a contour crossing, touching or running
|
||||
/// along another, or itself? Neighbouring edges of a ring share a corner and meet only where one
|
||||
/// folds back along the other. Edges are swept in order of their left ends, so only pairs whose
|
||||
/// bounds overlap across x are compared.
|
||||
pub(super) fn edges_meet(rings: &[Vec<Point>], touch: f64) -> bool {
|
||||
struct Edge {
|
||||
ring: usize,
|
||||
index: usize,
|
||||
a: Point,
|
||||
b: Point,
|
||||
x0: f64,
|
||||
x1: f64,
|
||||
}
|
||||
let mut edges: Vec<Edge> = rings
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(ring, r)| {
|
||||
r.iter().zip(r.iter().cycle().skip(1)).enumerate().map(move |(index, (a, b))| Edge {
|
||||
ring,
|
||||
index,
|
||||
a: *a,
|
||||
b: *b,
|
||||
x0: a.x.min(b.x),
|
||||
x1: a.x.max(b.x),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
edges.sort_by(|e, f| e.x0.total_cmp(&f.x0));
|
||||
let len = |ring: usize| rings.get(ring).map_or(0, Vec::len);
|
||||
let mut pairs = 0usize;
|
||||
for (i, e) in edges.iter().enumerate() {
|
||||
for f in edges.iter().skip(i + 1) {
|
||||
if f.x0 > e.x1 + touch {
|
||||
break;
|
||||
}
|
||||
pairs += 1;
|
||||
if pairs > MAX_EDGE_PAIRS {
|
||||
return true;
|
||||
}
|
||||
if e.a.y.min(e.b.y) > f.a.y.max(f.b.y) + touch || f.a.y.min(f.b.y) > e.a.y.max(e.b.y) + touch {
|
||||
continue;
|
||||
}
|
||||
let n = len(e.ring);
|
||||
let neighbours = e.ring == f.ring && ((e.index + 1) % n == f.index || (f.index + 1) % n == e.index);
|
||||
if neighbours {
|
||||
// They share a corner; they meet only if one runs back along the other.
|
||||
let (shared, p, q) = if e.b == f.a { (e.b, e.a, f.b) } else { (e.a, e.b, f.a) };
|
||||
if super::build::segment_distance(q, shared, p) <= touch || super::build::segment_distance(p, shared, q) <= touch {
|
||||
return true;
|
||||
}
|
||||
} else if segments_meet(e.a, e.b, f.a, f.b, touch) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Do segments `a`–`b` and `c`–`d` cross, or come within `touch` of each other?
|
||||
fn segments_meet(a: Point, b: Point, c: Point, d: Point, touch: f64) -> bool {
|
||||
let side = |p: Point, q: Point, r: Point| (q.x - p.x) * (r.y - p.y) - (q.y - p.y) * (r.x - p.x);
|
||||
let (d1, d2, d3, d4) = (side(c, d, a), side(c, d, b), side(a, b, c), side(a, b, d));
|
||||
if ((d1 > 0.0 && d2 < 0.0) || (d1 < 0.0 && d2 > 0.0)) && ((d3 > 0.0 && d4 < 0.0) || (d3 < 0.0 && d4 > 0.0)) {
|
||||
return true;
|
||||
}
|
||||
use super::build::segment_distance as dist;
|
||||
dist(a, c, d).min(dist(b, c, d)).min(dist(c, a, b)).min(dist(d, a, b)) <= touch
|
||||
}
|
||||
|
||||
/// The rings of a shape: closed polygons with the filled side on their left, (-dy, dx): outer
|
||||
/// outlines run one way and holes the other, so nonzero winding fills them as the art is. Curves
|
||||
/// are followed within a tolerance of 1/2000 of `size` (at least 0.05 pt), so small curves take
|
||||
@@ -28,11 +197,18 @@ fn pieces(c: vectorcraft_geom::CubicBez, tolerance: f64) -> u32 {
|
||||
/// is left out: a contour inside another that runs the same way doesn't cut a hole, as it would
|
||||
/// if nesting alone decided (#1724).
|
||||
pub(super) fn rings(path: &PathData, rule: FillRule, size: f64, limit: usize) -> Result<Vec<Vec<Point>>, String> {
|
||||
orient(sample(path, size, limit)?, rule)
|
||||
}
|
||||
|
||||
/// The closed polygons that follow `path`'s subpaths, as [`rings`] samples them, each running as
|
||||
/// its subpath does.
|
||||
pub(super) fn sample(path: &PathData, size: f64, limit: usize) -> Result<Vec<Vec<Point>>, String> {
|
||||
let tolerance = (size / 2000.0).max(0.05);
|
||||
let mut count = 0;
|
||||
let mut out = vec![];
|
||||
for sp in &path.subpaths {
|
||||
let segments = if sp.closed { sp.anchors.len() } else { sp.anchors.len().saturating_sub(1) };
|
||||
// A subpath of one anchor draws nothing, closed or not (the renderer and the boolean skip it).
|
||||
for sp in path.subpaths.iter().filter(|sp| sp.anchors.len() >= 2) {
|
||||
let segments = if sp.closed { sp.anchors.len() } else { sp.anchors.len() - 1 };
|
||||
if segments > limit {
|
||||
return Err("The shape is too complex for a 3D effect".into());
|
||||
}
|
||||
@@ -58,10 +234,21 @@ pub(super) fn rings(path: &PathData, rule: FillRule, size: f64, limit: usize) ->
|
||||
while ring.len() > 1 && ring.first().zip(ring.last()).is_some_and(|(a, b)| a.distance(*b) < 1e-9) {
|
||||
ring.pop();
|
||||
}
|
||||
if ring.len() >= 3 && signed_area(&ring).abs() > 1e-9 {
|
||||
// Rings with no area are kept here: a contour that crosses itself into lobes that cancel
|
||||
// (a bowtie) has none, and [`edges_meet`] must see it. [`orient`] leaves them out. A flat
|
||||
// ring, every point on one line, fills nothing under either rule and is left out now, so
|
||||
// that it doesn't send the profile through the boolean.
|
||||
if ring.len() >= 3 && !flat(&ring) {
|
||||
out.push(ring);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `out` (sampled rings) turned so the filled side is on each one's left, and under the nonzero
|
||||
/// `rule` without the rings that are no edge of the shape, as [`rings`] describes.
|
||||
fn orient(mut out: Vec<Vec<Point>>, rule: FillRule) -> Result<Vec<Vec<Point>>, String> {
|
||||
out.retain(|r| signed_area(r).abs() > 1e-9);
|
||||
// Which rings hold which: each ring against the others, its edges walked only where its
|
||||
// bounds hold the point, and within [`MAX_CONTAINMENT_EDGES`] edges and pairs in all (a pair
|
||||
// whose bounds miss the point costs a step too: many small rings are a square of pairs).
|
||||
@@ -118,6 +305,14 @@ pub(super) fn rings(path: &PathData, rule: FillRule, size: f64, limit: usize) ->
|
||||
/// the others, so many rings of many points each would otherwise take billions of steps.
|
||||
const MAX_CONTAINMENT_EDGES: usize = 50_000_000;
|
||||
|
||||
/// Does every point of `ring` lie on one line (within 1e-9, as [`sample`] merges points)?
|
||||
fn flat(ring: &[Point]) -> bool {
|
||||
let Some(&a) = ring.first() else { return true };
|
||||
let Some(&b) = ring.iter().max_by(|p, q| a.distance(**p).total_cmp(&a.distance(**q))) else { return true };
|
||||
let len = a.distance(b);
|
||||
len <= 1e-9 || ring.iter().all(|p| ((b - a).cross(*p - a) / len).abs() <= 1e-9)
|
||||
}
|
||||
|
||||
pub(super) fn signed_area(r: &[Point]) -> f64 {
|
||||
r.iter().zip(r.iter().cycle().skip(1)).take(r.len()).map(|(a, b)| a.x * b.y - b.x * a.y).sum::<f64>() / 2.0
|
||||
}
|
||||
|
||||
@@ -754,3 +754,407 @@ fn many_small_rings_far_apart_are_too_complex() {
|
||||
let e = rings(&p, FillRule::NonZero, r.width().max(r.height()), usize::MAX).unwrap_err();
|
||||
assert!(e.contains("too complex"), "{e}");
|
||||
}
|
||||
|
||||
/// Type often draws a glyph as contours that cross (a "k"'s arm across its stem, its leg across
|
||||
/// the arm). Each contour's side was told from its first point, so one starting inside another
|
||||
/// was dropped or cut as a hole, and the parts left were walled where they overlapped. The
|
||||
/// outline is the union the art fills, under either rule.
|
||||
#[test]
|
||||
fn crossing_contours_make_one_outline() {
|
||||
let rect = |r: Rect| {
|
||||
vectorcraft_geom::SubPath::polyline(&[Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)], true)
|
||||
};
|
||||
// The stem, an arm starting inside it, and a leg starting inside the arm.
|
||||
let k = PathData::new(vec![
|
||||
rect(Rect::new(0.0, 0.0, 20.0, 100.0)),
|
||||
rect(Rect::new(10.0, 40.0, 60.0, 60.0)),
|
||||
rect(Rect::new(50.0, 50.0, 70.0, 100.0)),
|
||||
]);
|
||||
let b = k.bounds().unwrap();
|
||||
for rule in [FillRule::NonZero, FillRule::EvenOdd] {
|
||||
let filled = vectorcraft_pathops::area(&k, rule);
|
||||
assert!(close(area(&plane(&k, rule, b, front()).unwrap()), filled, 1e-6), "{rule:?}: Rotate");
|
||||
let f = extrude(&k, rule, b, Extrude::default(), front()).unwrap();
|
||||
assert_eq!(f.len(), 1, "{rule:?}: straight on, only the front cap shows");
|
||||
assert!(close(area(&f), filled, 1e-6), "{rule:?}: Extrude's cap");
|
||||
let puffed = inflate(&k, rule, b, Inflate::default(), front()).unwrap();
|
||||
assert_eq!(extent(&puffed), b, "{rule:?}: Inflate covers the whole k");
|
||||
assert!(close(area(&puffed), filled, filled * 1e-3), "{rule:?}: Inflate's front, {} against {filled}", area(&puffed));
|
||||
}
|
||||
// The filled area is the union's: the stem, the arm past it and the leg past the arm.
|
||||
assert!(close(vectorcraft_pathops::area(&k, FillRule::NonZero), 20.0 * 100.0 + 40.0 * 20.0 + 20.0 * 50.0 - 10.0 * 10.0, 1e-6));
|
||||
}
|
||||
|
||||
/// Resolving overlaps keeps every contour that fills area, however narrow: a hairline beside a
|
||||
/// large shape is art. What a boolean leaves where two curves coincide is still dropped.
|
||||
#[test]
|
||||
fn resolving_overlaps_keeps_narrow_contours() {
|
||||
let rect = |r: Rect| {
|
||||
vectorcraft_geom::SubPath::polyline(&[Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)], true)
|
||||
};
|
||||
// A hairline beside a square, and two bars crossing elsewhere so the profile is resolved.
|
||||
let hairline = Rect::new(120.0, 0.0, 120.005, 50.0);
|
||||
let p = PathData::new(vec![
|
||||
rect(Rect::new(0.0, 0.0, 100.0, 100.0)),
|
||||
rect(hairline),
|
||||
rect(Rect::new(140.0, 40.0, 200.0, 60.0)),
|
||||
rect(Rect::new(160.0, 20.0, 180.0, 80.0)),
|
||||
]);
|
||||
let b = p.bounds().unwrap();
|
||||
let filled = |rule: FillRule| {
|
||||
let bars = if rule == FillRule::NonZero { 60.0 * 20.0 + 20.0 * 40.0 } else { 60.0 * 20.0 + 20.0 * 40.0 - 20.0 * 20.0 };
|
||||
100.0 * 100.0 + hairline.width() * hairline.height() + bars
|
||||
};
|
||||
for rule in [FillRule::NonZero, FillRule::EvenOdd] {
|
||||
assert!(close(area(&plane(&p, rule, b, front()).unwrap()), filled(rule), 1e-6), "{rule:?}: Rotate");
|
||||
assert!(close(area(&extrude(&p, rule, b, Extrude::default(), front()).unwrap()), filled(rule), 1e-6), "{rule:?}: Extrude's cap");
|
||||
}
|
||||
// A circle drawn twice, exactly and a hair off, is one outline: no sliver between the copies.
|
||||
let circle = |cx: f64, cy: f64, r: f64| {
|
||||
let (mut b, k) = (BezPath::new(), 0.5523 * r);
|
||||
b.move_to((cx + r, cy));
|
||||
b.curve_to((cx + r, cy + k), (cx + k, cy + r), (cx, cy + r));
|
||||
b.curve_to((cx - k, cy + r), (cx - r, cy + k), (cx - r, cy));
|
||||
b.curve_to((cx - r, cy - k), (cx - k, cy - r), (cx, cy - r));
|
||||
b.curve_to((cx + k, cy - r), (cx + r, cy - k), (cx + r, cy));
|
||||
b.close_path();
|
||||
b
|
||||
};
|
||||
for dx in [0.0, 1e-7] {
|
||||
let mut twice = circle(50.0, 50.0, 40.0);
|
||||
twice.extend(circle(50.0 + dx, 50.0, 40.0).iter());
|
||||
let p = PathData::from_bezpath(&twice);
|
||||
let f = plane(&p, FillRule::NonZero, p.bounds().unwrap(), front()).unwrap();
|
||||
assert_eq!(f.len(), 1, "{dx}");
|
||||
assert_eq!(f[0].contours.len(), 1, "{dx}");
|
||||
let once = PathData::from_bezpath(&circle(50.0, 50.0, 40.0));
|
||||
let one = area(&plane(&once, FillRule::NonZero, once.bounds().unwrap(), front()).unwrap());
|
||||
assert!(close(area(&f), one, one * 1e-6), "{dx}: the area of one circle, {} against {one}", area(&f));
|
||||
}
|
||||
}
|
||||
|
||||
/// Only a profile whose contours meet goes through the boolean: crossing, touching, running along
|
||||
/// another or folding back on itself. Separate and nested contours are taken as they are.
|
||||
#[test]
|
||||
fn contours_meet_when_they_cross_touch_or_fold_back() {
|
||||
let ring = |pts: &[(f64, f64)]| pts.iter().map(|&(x, y)| Point::new(x, y)).collect::<Vec<_>>();
|
||||
let square = |x: f64, y: f64, s: f64| ring(&[(x, y), (x + s, y), (x + s, y + s), (x, y + s)]);
|
||||
assert!(!rings::edges_meet(&[square(0.0, 0.0, 10.0), square(20.0, 0.0, 10.0)], 1e-6), "apart");
|
||||
assert!(!rings::edges_meet(&[square(0.0, 0.0, 10.0), square(3.0, 3.0, 4.0)], 1e-6), "nested, as an o's hole");
|
||||
assert!(rings::edges_meet(&[square(0.0, 0.0, 10.0), square(5.0, 5.0, 10.0)], 1e-6), "crossing");
|
||||
assert!(rings::edges_meet(&[square(0.0, 0.0, 10.0), square(10.0, 0.0, 10.0)], 1e-6), "sharing an edge");
|
||||
assert!(rings::edges_meet(&[square(0.0, 0.0, 10.0), square(10.0, 3.0, 4.0)], 1e-6), "along part of an edge");
|
||||
assert!(rings::edges_meet(&[square(0.0, 0.0, 10.0), square(10.0, 10.0, 4.0)], 1e-6), "at a corner");
|
||||
assert!(rings::edges_meet(&[square(0.0, 0.0, 10.0), square(10.0 + 1e-7, 0.0, 10.0)], 1e-6), "within the boolean's snapping");
|
||||
assert!(rings::edges_meet(&[ring(&[(0.0, 0.0), (10.0, 10.0), (10.0, 0.0), (0.0, 10.0)])], 1e-6), "a bowtie crossing itself");
|
||||
assert!(rings::edges_meet(&[ring(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (10.0, 5.0), (0.0, 10.0)])], 1e-6), "folding back on itself");
|
||||
// Squares sharing an edge inflate as one puff, not two with a seam between them.
|
||||
let shared = PathData::new(
|
||||
[Rect::new(0.0, 0.0, 50.0, 50.0), Rect::new(50.0, 0.0, 100.0, 50.0)]
|
||||
.iter()
|
||||
.map(|r| {
|
||||
vectorcraft_geom::SubPath::polyline(
|
||||
&[Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)],
|
||||
true,
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
let whole = PathData::from_bezpath(&{
|
||||
let mut b = BezPath::new();
|
||||
b.move_to((0.0, 0.0));
|
||||
b.line_to((100.0, 0.0));
|
||||
b.line_to((100.0, 50.0));
|
||||
b.line_to((0.0, 50.0));
|
||||
b.close_path();
|
||||
b
|
||||
});
|
||||
let b = whole.bounds().unwrap();
|
||||
let puff = |p: &PathData| inflate(p, FillRule::NonZero, b, Inflate::default(), front()).unwrap().len();
|
||||
assert_eq!(puff(&shared), puff(&whole));
|
||||
}
|
||||
|
||||
/// Flat contours fill nothing and build nothing, crossing or not; an invalid coordinate is refused,
|
||||
/// as it was; and a profile the boolean refuses is built from its contours as drawn.
|
||||
#[test]
|
||||
fn flat_invalid_and_refused_profiles() {
|
||||
let flat =
|
||||
PathData::new(vec![vectorcraft_geom::SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 10.0), Point::new(20.0, 20.0)], true)]);
|
||||
let crossing_flat = PathData::new(vec![
|
||||
vectorcraft_geom::SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 10.0), Point::new(20.0, 20.0)], true),
|
||||
vectorcraft_geom::SubPath::polyline(&[Point::new(0.0, 20.0), Point::new(10.0, 10.0), Point::new(20.0, 0.0)], true),
|
||||
]);
|
||||
let b = Rect::new(0.0, 0.0, 20.0, 20.0);
|
||||
for p in [&flat, &crossing_flat] {
|
||||
assert_eq!(area(&plane(p, FillRule::NonZero, b, front()).unwrap()), 0.0);
|
||||
assert_eq!(area(&extrude(p, FillRule::NonZero, b, Extrude::default(), front()).unwrap()), 0.0);
|
||||
}
|
||||
let mut bad = crossing_flat.clone();
|
||||
bad.subpaths[0].anchors[1].p = Point::new(f64::NAN, 10.0);
|
||||
assert_eq!(plane(&bad, FillRule::NonZero, b, front()).unwrap_err(), "The shape contains invalid coordinates");
|
||||
// A star of 3,001 points, each joined to the one 1,500 on: every edge spans the middle, so
|
||||
// past pathops' budget of segment pairs the boolean refuses it (`TooComplex`) without sweeping,
|
||||
// and the profile is turned as drawn.
|
||||
let n = 3001;
|
||||
let star: Vec<Point> = (0..n)
|
||||
.map(|i| {
|
||||
let a = std::f64::consts::TAU * ((i * 1500) % n) as f64 / n as f64;
|
||||
Point::new(100.0 + 100.0 * a.cos(), 100.0 + 100.0 * a.sin())
|
||||
})
|
||||
.collect();
|
||||
let star = PathData::new(vec![vectorcraft_geom::SubPath::polyline(&star, true)]);
|
||||
assert_eq!(
|
||||
vectorcraft_pathops::try_unite_all_with(&[(&star, FillRule::NonZero)], rings::DEBRIS),
|
||||
Err(vectorcraft_pathops::PathOpsError::TooComplex)
|
||||
);
|
||||
let b = star.bounds().unwrap();
|
||||
let drawn: f64 =
|
||||
rings::rings(&star, FillRule::NonZero, b.width().max(b.height()), usize::MAX).unwrap().iter().map(|r| rings::signed_area(r)).sum();
|
||||
assert!(drawn > 0.0);
|
||||
assert!(close(area(&plane(&star, FillRule::NonZero, b, front()).unwrap()), drawn, 1e-6), "built from its contours as drawn");
|
||||
}
|
||||
|
||||
/// A contour that crosses itself into lobes that cancel has no area of its own, yet fills both
|
||||
/// lobes; contours that cancel each other fill nothing. Both go through the boolean.
|
||||
#[test]
|
||||
fn self_crossing_and_cancelling_profiles_are_resolved() {
|
||||
let poly = |pts: &[(f64, f64)]| vectorcraft_geom::SubPath::polyline(&pts.iter().map(|&(x, y)| Point::new(x, y)).collect::<Vec<_>>(), true);
|
||||
let bowtie = PathData::new(vec![poly(&[(0.0, 0.0), (10.0, 10.0), (10.0, 0.0), (0.0, 10.0)])]);
|
||||
let b = bowtie.bounds().unwrap();
|
||||
for rule in [FillRule::NonZero, FillRule::EvenOdd] {
|
||||
assert!(close(area(&plane(&bowtie, rule, b, front()).unwrap()), 50.0, 1e-6), "{rule:?}: Rotate");
|
||||
assert!(close(area(&extrude(&bowtie, rule, b, Extrude::default(), front()).unwrap()), 50.0, 1e-6), "{rule:?}: Extrude's cap");
|
||||
assert!(inflate(&bowtie, rule, b, Inflate::default(), front()).is_ok_and(|f| close(area(&f), 50.0, 1e-6)), "{rule:?}: Inflate");
|
||||
}
|
||||
let square = poly(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]);
|
||||
let twice = PathData::new(vec![square.clone(), square]);
|
||||
let b = twice.bounds().unwrap();
|
||||
assert_eq!(area(&plane(&twice, FillRule::EvenOdd, b, front()).unwrap()), 0.0);
|
||||
assert_eq!(area(&extrude(&twice, FillRule::EvenOdd, b, Extrude::default(), front()).unwrap()), 0.0);
|
||||
assert!(inflate(&twice, FillRule::EvenOdd, b, Inflate::default(), front()).is_ok_and(|f| area(&f) == 0.0));
|
||||
}
|
||||
|
||||
/// The boolean's answer often starts a contour where it touches another. Its rings are turned as
|
||||
/// a whole, never judged from one point each, so touching parts are kept and holes stay open.
|
||||
#[test]
|
||||
fn touching_contours_keep_their_parts_and_holes() {
|
||||
let poly = |pts: &[(f64, f64)]| vectorcraft_geom::SubPath::polyline(&pts.iter().map(|&(x, y)| Point::new(x, y)).collect::<Vec<_>>(), true);
|
||||
// A triangle whose tip rests on a square's edge.
|
||||
let tip = PathData::new(vec![poly(&[(0.0, 10.0), (10.0, 10.0), (10.0, 20.0), (0.0, 20.0)]), poly(&[(0.0, 0.0), (10.0, 0.0), (5.0, 10.0)])]);
|
||||
let b = tip.bounds().unwrap();
|
||||
for rule in [FillRule::NonZero, FillRule::EvenOdd] {
|
||||
assert!(close(area(&plane(&tip, rule, b, front()).unwrap()), 150.0, 1e-6), "{rule:?}: Rotate");
|
||||
assert!(close(area(&extrude(&tip, rule, b, Extrude::default(), front()).unwrap()), 150.0, 1e-6), "{rule:?}: Extrude's cap");
|
||||
}
|
||||
// An even-odd triangular hole touching its square's edge.
|
||||
let hole =
|
||||
PathData::new(vec![poly(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)]), poly(&[(0.0, 50.0), (60.0, 0.0 + 20.0), (60.0, 80.0)])]);
|
||||
let b = hole.bounds().unwrap();
|
||||
assert!(close(area(&plane(&hole, FillRule::EvenOdd, b, front()).unwrap()), 10000.0 - 1800.0, 1e-6));
|
||||
}
|
||||
|
||||
/// An open subpath is closed by a straight line, whichever way the profile goes: the same as the
|
||||
/// renderer's fill, whether or not other contours meet.
|
||||
#[test]
|
||||
fn an_open_subpath_closes_straight_whether_or_not_contours_meet() {
|
||||
let open = vectorcraft_geom::SubPath {
|
||||
anchors: vec![
|
||||
vectorcraft_geom::Anchor {
|
||||
p: Point::new(0.0, 0.0),
|
||||
h_in: Point::new(0.0, -50.0),
|
||||
h_out: Point::new(0.0, 0.0),
|
||||
kind: vectorcraft_geom::AnchorKind::Corner,
|
||||
},
|
||||
vectorcraft_geom::Anchor::corner(Point::new(50.0, 100.0)),
|
||||
vectorcraft_geom::Anchor {
|
||||
p: Point::new(100.0, 0.0),
|
||||
h_in: Point::new(100.0, 0.0),
|
||||
h_out: Point::new(100.0, -50.0),
|
||||
kind: vectorcraft_geom::AnchorKind::Corner,
|
||||
},
|
||||
],
|
||||
closed: false,
|
||||
};
|
||||
let rect = |x0: f64, y0: f64, x1: f64, y1: f64| {
|
||||
vectorcraft_geom::SubPath::polyline(&[Point::new(x0, y0), Point::new(x1, y0), Point::new(x1, y1), Point::new(x0, y1)], true)
|
||||
};
|
||||
let alone = PathData::new(vec![open.clone()]);
|
||||
let with_bars = PathData::new(vec![open, rect(200.0, 40.0, 260.0, 60.0), rect(220.0, 20.0, 240.0, 80.0)]);
|
||||
let bars = 60.0 * 20.0 + 20.0 * 40.0;
|
||||
assert!(close(area(&plane(&alone, FillRule::NonZero, alone.bounds().unwrap(), front()).unwrap()), 5000.0, 1e-6));
|
||||
assert!(close(area(&plane(&with_bars, FillRule::NonZero, with_bars.bounds().unwrap(), front()).unwrap()), 5000.0 + bars, 1e-6));
|
||||
}
|
||||
|
||||
/// A flat contour beside separate ones fills nothing and doesn't send the profile through the
|
||||
/// boolean; contours far from the origin meet within the boolean's wider snapping; and a merged
|
||||
/// outline past the point limit is too complex.
|
||||
#[test]
|
||||
fn flat_far_and_too_large_merges() {
|
||||
let rect = |x0: f64, y0: f64, x1: f64, y1: f64| {
|
||||
vectorcraft_geom::SubPath::polyline(&[Point::new(x0, y0), Point::new(x1, y0), Point::new(x1, y1), Point::new(x0, y1)], true)
|
||||
};
|
||||
let squares: Vec<_> = (0..12).map(|i| rect(i as f64 * 20.0, 0.0, i as f64 * 20.0 + 10.0, 10.0)).collect();
|
||||
let line = vectorcraft_geom::SubPath::polyline(&[Point::new(0.0, 300.0), Point::new(50.0, 300.0), Point::new(100.0, 300.0)], false);
|
||||
let with_line = PathData::new(squares.iter().cloned().chain([line]).collect());
|
||||
assert!(!rings::meets(&rings::sample(&with_line, 300.0, usize::MAX).unwrap(), &rings::closed_straight(&with_line)));
|
||||
assert!(close(area(&plane(&with_line, FillRule::NonZero, with_line.bounds().unwrap(), front()).unwrap()), 1200.0, 1e-6));
|
||||
// Far from the origin the boolean snaps wider than 1e-6 (1.4e-6 at 9.9e7), and so does the
|
||||
// test for meeting: squares 1.35e-6 apart there are one outline, one cap of both areas.
|
||||
let far = PathData::new(vec![rect(9.9e7, 0.0, 9.9e7 + 10.0, 10.0), rect(9.9e7 + 10.0 + 1.35e-6, 0.0, 9.9e7 + 20.0, 10.0)]);
|
||||
let cap = plane(&far, FillRule::NonZero, far.bounds().unwrap(), front()).unwrap();
|
||||
assert_eq!(cap.iter().map(|f| f.contours.len()).sum::<usize>(), 1, "joined into one outline");
|
||||
assert!(close(area(&cap), 200.0, 1e-3), "joined, not lost");
|
||||
// Handles count too: a lobe whose handles reach 1.3e8 widens the boolean's snapping to 1.85e-6.
|
||||
let lobe = vectorcraft_geom::SubPath {
|
||||
anchors: vec![
|
||||
vectorcraft_geom::Anchor {
|
||||
p: Point::new(0.0, 1000.0),
|
||||
h_in: Point::new(0.0, 1000.0),
|
||||
h_out: Point::new(1.3e8, 1000.0),
|
||||
kind: vectorcraft_geom::AnchorKind::Corner,
|
||||
},
|
||||
vectorcraft_geom::Anchor {
|
||||
p: Point::new(0.0, 1100.0),
|
||||
h_in: Point::new(1.3e8, 1100.0),
|
||||
h_out: Point::new(0.0, 1100.0),
|
||||
kind: vectorcraft_geom::AnchorKind::Corner,
|
||||
},
|
||||
],
|
||||
closed: true,
|
||||
};
|
||||
let wide = PathData::new(vec![rect(9.9e7, 0.0, 9.9e7 + 10.0, 10.0), rect(9.9e7 + 10.0 + 1.6e-6, 0.0, 9.9e7 + 20.0, 10.0), lobe]);
|
||||
let b = wide.bounds().unwrap();
|
||||
let joined = plane(&wide, FillRule::NonZero, b, front()).unwrap();
|
||||
assert_eq!(joined.iter().map(|f| f.contours.len()).sum::<usize>(), 2, "the squares joined, the lobe apart");
|
||||
// 24 bars each way: 192 points as drawn, some 2,400 once every hole between them is traced:
|
||||
// past Extrude's and Inflate's 2,048 the shape is too complex (its contours as drawn cross, so
|
||||
// they can't be built as they are), and Rotate's 65,536 takes it.
|
||||
let mut bars = vec![];
|
||||
for i in 0..24 {
|
||||
let x = i as f64 * 10.0;
|
||||
bars.push(rect(x, 0.0, x + 4.0, 240.0));
|
||||
bars.push(rect(0.0, x, 240.0, x + 4.0));
|
||||
}
|
||||
let grid = PathData::new(bars);
|
||||
let b = grid.bounds().unwrap();
|
||||
for rule in [FillRule::NonZero, FillRule::EvenOdd] {
|
||||
assert_eq!(extrude(&grid, rule, b, Extrude::default(), front()).unwrap_err(), "The shape is too complex for a 3D effect", "{rule:?}");
|
||||
assert_eq!(inflate(&grid, rule, b, Inflate::default(), front()).unwrap_err(), "The shape is too complex for a 3D effect", "{rule:?}");
|
||||
assert!(close(area(&plane(&grid, rule, b, front()).unwrap()), vectorcraft_pathops::area(&grid, rule), 1e-6), "{rule:?}: Rotate");
|
||||
}
|
||||
}
|
||||
|
||||
/// A profile goes through the boolean when its contours come within twice the boolean's tolerance
|
||||
/// of the path it is given: pathops snaps anchors onto nearby heights before sweeping, then the
|
||||
/// sweep joins edges within its tolerance, so contours a little more than it apart can come out
|
||||
/// as one.
|
||||
#[test]
|
||||
fn meets_within_twice_the_boolean_tolerance() {
|
||||
let rect = |x0: f64, y0: f64, x1: f64, y1: f64| {
|
||||
vectorcraft_geom::SubPath::polyline(&[Point::new(x0, y0), Point::new(x1, y0), Point::new(x1, y1), Point::new(x0, y1)], true)
|
||||
};
|
||||
let meets = |p: &PathData| rings::meets(&rings::sample(p, 300.0, usize::MAX).unwrap(), &rings::closed_straight(p));
|
||||
// Two triangles whose facing 45° edges are 1.4e-6 apart, the second's corners 0.99e-6 lower on
|
||||
// the page (greater y) than the first's: snapped onto the first's heights, they are joined.
|
||||
let d = 0.99e-6;
|
||||
let snapped = PathData::new(vec![
|
||||
vectorcraft_geom::SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(0.0, 10.0)], true),
|
||||
vectorcraft_geom::SubPath::polyline(&[Point::new(10.0 + d, d), Point::new(10.0 + d, 10.0 + d), Point::new(d, 10.0 + d)], true),
|
||||
]);
|
||||
assert!(meets(&snapped));
|
||||
let cap = plane(&snapped, FillRule::NonZero, snapped.bounds().unwrap(), front()).unwrap();
|
||||
assert_eq!(cap.iter().map(|f| f.contours.len()).sum::<usize>(), 1, "joined into one outline");
|
||||
assert!(close(area(&cap), 100.0, 1e-3), "joined, not lost");
|
||||
// At the same heights the boolean leaves the triangles apart, and the shape it builds is the
|
||||
// one drawn: both triangles, two outlines.
|
||||
let tri = |g: f64| {
|
||||
PathData::new(vec![
|
||||
vectorcraft_geom::SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(0.0, 10.0)], true),
|
||||
vectorcraft_geom::SubPath::polyline(&[Point::new(10.0 + g, 0.0), Point::new(10.0 + g, 10.0), Point::new(g, 10.0)], true),
|
||||
])
|
||||
};
|
||||
let apart = tri(1.4e-6 * std::f64::consts::SQRT_2);
|
||||
assert!(meets(&apart));
|
||||
assert_eq!(vectorcraft_pathops::try_unite_all_with(&[(&apart, FillRule::NonZero)], rings::DEBRIS).map(|p| p.subpaths.len()), Ok(2));
|
||||
let cap = plane(&apart, FillRule::NonZero, apart.bounds().unwrap(), front()).unwrap();
|
||||
assert_eq!(cap.iter().map(|f| f.contours.len()).sum::<usize>(), 2);
|
||||
assert!(close(area(&cap), 100.0, 1e-3), "both kept");
|
||||
// Twice the tolerance, not less: 45° edges 1.9e-6 apart still meet.
|
||||
assert!(meets(&tri(1.9e-6 * std::f64::consts::SQRT_2)));
|
||||
// And pathops joins nothing past it: the worst case, 2.05e-6 apart as `meets` measures them,
|
||||
// with the second triangle's corners just under the tolerance lower so that the snap brings it
|
||||
// closest, stays two outlines.
|
||||
let lift = 0.999e-6;
|
||||
let g = 2.05e-6 * std::f64::consts::SQRT_2 - lift;
|
||||
let past = PathData::new(vec![
|
||||
vectorcraft_geom::SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(0.0, 10.0)], true),
|
||||
vectorcraft_geom::SubPath::polyline(&[Point::new(10.0 + g, lift), Point::new(10.0 + g, 10.0 + lift), Point::new(g, 10.0 + lift)], true),
|
||||
]);
|
||||
assert!(!meets(&past));
|
||||
assert_eq!(vectorcraft_pathops::try_unite_all_with(&[(&past, FillRule::NonZero)], rings::DEBRIS).map(|p| p.subpaths.len()), Ok(2));
|
||||
// Near the origin, contours a few millionths apart stay apart and skip the boolean.
|
||||
assert!(!meets(&PathData::new(vec![rect(0.0, 0.0, 10.0, 10.0), rect(10.0 + 2.2e-6, 0.0, 20.0, 10.0)])));
|
||||
// What the boolean is given decides the tolerance: an open subpath's end handles (closed
|
||||
// straight) and a one-anchor subpath (skipped) don't widen it.
|
||||
let ends = vectorcraft_geom::SubPath {
|
||||
anchors: vec![
|
||||
vectorcraft_geom::Anchor {
|
||||
p: Point::new(0.0, 0.0),
|
||||
h_in: Point::new(1e9, 0.0),
|
||||
h_out: Point::new(0.0, 0.0),
|
||||
kind: vectorcraft_geom::AnchorKind::Corner,
|
||||
},
|
||||
vectorcraft_geom::Anchor {
|
||||
p: Point::new(10.0, 0.0),
|
||||
h_in: Point::new(10.0, 0.0),
|
||||
h_out: Point::new(1e9, 0.0),
|
||||
kind: vectorcraft_geom::AnchorKind::Corner,
|
||||
},
|
||||
],
|
||||
closed: false,
|
||||
};
|
||||
let speck = vectorcraft_geom::SubPath { anchors: vec![vectorcraft_geom::Anchor::corner(Point::new(1e9, 1e9))], closed: true };
|
||||
let quiet = PathData::new(vec![ends, speck]);
|
||||
assert_eq!(rings::touch(&rings::closed_straight(&quiet)), 1e-6);
|
||||
// A closing-side handle at either end alone is enough to close the subpath straight.
|
||||
for kept in [0, 1] {
|
||||
let mut one = quiet.subpaths[0].clone();
|
||||
one.anchors[0].h_in = if kept == 0 { Point::new(1e9, 0.0) } else { one.anchors[0].p };
|
||||
one.anchors[1].h_out = if kept == 1 { Point::new(1e9, 0.0) } else { one.anchors[1].p };
|
||||
assert_eq!(rings::touch(&rings::closed_straight(&PathData::new(vec![one]))), 1e-6, "handle kept at anchor {kept}");
|
||||
}
|
||||
// Beside two squares 3e-6 apart near the origin (farther apart than twice 1e-6), they don't
|
||||
// send the profile through the boolean; read unclosed, the end handles would.
|
||||
let mut quiet_squares = quiet.clone();
|
||||
quiet_squares.subpaths.extend([rect(0.0, 100.0, 10.0, 110.0), rect(10.0 + 3e-6, 100.0, 20.0, 110.0)]);
|
||||
assert!(!meets(&quiet_squares));
|
||||
}
|
||||
|
||||
/// The boolean's rings are turned as a whole so the filled side is on their left: holes stay
|
||||
/// against their outlines, and a profile wound the other way round is turned over.
|
||||
#[test]
|
||||
fn the_merged_rings_are_turned_as_a_whole() {
|
||||
let outline = vec![Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(10.0, 10.0), Point::new(0.0, 10.0)];
|
||||
let hole = vec![Point::new(2.0, 2.0), Point::new(2.0, 8.0), Point::new(8.0, 8.0), Point::new(8.0, 2.0)];
|
||||
let turned = rings::wound(vec![outline.iter().rev().copied().collect(), hole.iter().rev().copied().collect()]);
|
||||
assert!(rings::signed_area(&turned[0]) > 0.0 && rings::signed_area(&turned[1]) < 0.0);
|
||||
let kept = rings::wound(vec![outline.clone(), hole.clone()]);
|
||||
assert_eq!(kept, vec![outline, hole]);
|
||||
}
|
||||
|
||||
/// A subpath of one anchor draws nothing, so it builds nothing, beside other art or not.
|
||||
#[test]
|
||||
fn a_one_anchor_subpath_builds_nothing() {
|
||||
let loop_ = vectorcraft_geom::SubPath {
|
||||
anchors: vec![vectorcraft_geom::Anchor {
|
||||
p: Point::new(0.0, 0.0),
|
||||
h_in: Point::new(-20.0, 30.0),
|
||||
h_out: Point::new(20.0, 30.0),
|
||||
kind: vectorcraft_geom::AnchorKind::Corner,
|
||||
}],
|
||||
closed: true,
|
||||
};
|
||||
let p = PathData::new(vec![loop_]);
|
||||
let b = Rect::new(-20.0, 0.0, 20.0, 30.0);
|
||||
assert_eq!(area(&plane(&p, FillRule::NonZero, b, front()).unwrap()), 0.0);
|
||||
}
|
||||
|
||||
@@ -257,7 +257,7 @@ pub struct CornerDrag {
|
||||
}
|
||||
|
||||
/// Direct Selection: the drawn anchors of a widget's corner, which a click on the widget selects
|
||||
/// (Shift: toggles), as a click on the corner's anchor does.
|
||||
/// (Shift: toggles, unless every corner is selected), as a click on the corner's anchor does.
|
||||
#[derive(Clone, Debug)]
|
||||
struct CornerClick {
|
||||
id: NodeId,
|
||||
@@ -282,7 +282,13 @@ impl CornerDrag {
|
||||
let path = widgets.paths.get(widget.0)?;
|
||||
let corner = path.widgets.get(widget.1)?.corner.index;
|
||||
let lc = cx.doc.node(path.id).and_then(LiveCorners::of)?;
|
||||
Some(CornerClick { id: path.id, anchors: lc.anchors_of(&[corner].into()).into_iter().collect(), shift: ev.mods.shift })
|
||||
// While every corner is selected (the path selected whole), a Shift-click picks
|
||||
// this corner alone, as the first click of "click one widget, then Shift-click the
|
||||
// others" does: toggling it would deselect it and leave the rest (#5188). Selected
|
||||
// anchors that are no corner show every widget too, but select no corner: there a
|
||||
// Shift-click adds the corner.
|
||||
let every = cx.selection.partial(path.id).is_none_or(|anchors| lc.all().is_subset(&lc.corners_of(anchors)));
|
||||
Some(CornerClick { id: path.id, anchors: lc.anchors_of(&[corner].into()).into_iter().collect(), shift: ev.mods.shift && !every })
|
||||
})
|
||||
.flatten();
|
||||
let outlines = widgets
|
||||
|
||||
@@ -196,7 +196,9 @@ fn drag_follows_a_rotated_shape_and_a_click_does_nothing() {
|
||||
|
||||
/// Direct Selection: a click on a widget (no drag) selects its corner, as a click on the
|
||||
/// corner's anchor does, so the next drag on that widget rounds that corner alone; Shift
|
||||
/// toggles the corner. The Selection tool's click still does nothing.
|
||||
/// toggles the corner, except while every corner is selected (#5188, see
|
||||
/// `a_shift_click_on_a_widget_of_a_whole_path_picks_its_corner`). The Selection tool's click
|
||||
/// still does nothing.
|
||||
#[test]
|
||||
fn a_click_on_a_widget_with_direct_selection_selects_its_corner() {
|
||||
let (d, id) = live_rect(10.0, Affine::translate((100.0, 100.0)));
|
||||
@@ -211,20 +213,80 @@ fn a_click_on_a_widget_with_direct_selection_selects_its_corner() {
|
||||
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, w.x, w.y)).is_empty());
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, w.x, w.y));
|
||||
assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": anchors, "mode": "set"}))]);
|
||||
let shift = Mods { shift: true, ..Mods::default() };
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, w.x, w.y).with_mods(shift));
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, w.x, w.y).with_mods(shift));
|
||||
assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": anchors, "mode": "toggle"}))]);
|
||||
// With that corner selected, its widget alone shows, and a drag rounds it alone
|
||||
// (`direct_selected_corners_show_their_widgets_alone_and_round_alone`).
|
||||
let mut part = selected(id);
|
||||
part.anchors.insert(id, anchors.iter().copied().collect());
|
||||
assert_eq!(CornerWidgets::of(&d, &part, 1.0, true).unwrap().corners(), [2]);
|
||||
// A Shift-click on it then deselects it.
|
||||
let shift = Mods { shift: true, ..Mods::default() };
|
||||
let one = crate::testutil::cx(&d, &part, &p);
|
||||
t.pointer(&one, &PointerEvent::new(PointerKind::Down, w.x, w.y).with_mods(shift));
|
||||
let a = t.pointer(&one, &PointerEvent::new(PointerKind::Up, w.x, w.y).with_mods(shift));
|
||||
assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": anchors, "mode": "toggle"}))]);
|
||||
let mut t = crate::create("selection");
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, w.x, w.y));
|
||||
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, w.x, w.y)).is_empty());
|
||||
}
|
||||
|
||||
/// #5188: while every corner of a path is selected (the path selected whole), a Shift-click on a
|
||||
/// widget picks that corner alone, as the first click of "click one widget, then Shift-click the
|
||||
/// others" does, rather than deselecting it and leaving the others. Once some corners are picked,
|
||||
/// Shift-clicks toggle.
|
||||
#[test]
|
||||
fn a_shift_click_on_a_widget_of_a_whole_path_picks_its_corner() {
|
||||
let (d, id) = live_rect(10.0, Affine::translate((100.0, 100.0)));
|
||||
let p = paint();
|
||||
let lc = LiveCorners::of(d.node(id).unwrap()).unwrap();
|
||||
let anchors = |k: usize| -> Vec<(usize, usize)> { lc.anchors_of(&[k].into()).into_iter().collect() };
|
||||
let shift = Mods { shift: true, ..Mods::default() };
|
||||
let click = |s: &Selection, at: Point| {
|
||||
let cx = crate::testutil::cx(&d, s, &p);
|
||||
let mut t = crate::create("directSelection");
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y).with_mods(shift));
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, at.x, at.y).with_mods(shift))
|
||||
};
|
||||
let pick = |k: usize, mode: &str| vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": anchors(k), "mode": mode}))];
|
||||
// Selected whole: every widget shows; a Shift-click on one picks its corner alone.
|
||||
let whole = selected(id);
|
||||
let w = pts(&CornerWidgets::of(&d, &whole, 1.0, true).unwrap());
|
||||
assert_eq!(w.len(), 4);
|
||||
assert_eq!(click(&whole, w[2]), pick(2, "set"));
|
||||
// Every anchor direct-selected is every corner selected too.
|
||||
let mut every = selected(id);
|
||||
every.anchors.insert(id, (0..4).flat_map(anchors).collect());
|
||||
assert_eq!(click(&every, w[1]), pick(1, "set"));
|
||||
// Two corners picked: a Shift-click on either widget deselects that corner.
|
||||
let mut two = selected(id);
|
||||
two.anchors.insert(id, [0, 2].into_iter().flat_map(anchors).collect());
|
||||
let shown = pts(&CornerWidgets::of(&d, &two, 1.0, true).unwrap());
|
||||
assert_eq!(shown, [w[0], w[2]]);
|
||||
assert_eq!(click(&two, w[0]), pick(0, "toggle"));
|
||||
}
|
||||
|
||||
/// #5188: an anchor selected that is no corner (here half way along a side) shows every widget
|
||||
/// but selects no corner, so a Shift-click on a widget adds its corner to that selection.
|
||||
#[test]
|
||||
fn a_shift_click_on_a_widget_adds_to_anchors_that_are_no_corner() {
|
||||
let corners = [(100.0, 100.0), (150.0, 100.0), (200.0, 100.0), (200.0, 200.0), (100.0, 200.0)].map(|(x, y)| Point::new(x, y));
|
||||
let (d, id) = doc_with(PathData::single(vectorcraft_geom::SubPath::polyline(&corners, true)), None);
|
||||
let p = paint();
|
||||
let mut s = selected(id);
|
||||
s.anchors.insert(id, [(0, 1)].into());
|
||||
let cx = cx(&d, &s, &p);
|
||||
let widgets = CornerWidgets::for_tool(&cx, true).unwrap();
|
||||
assert_eq!(widgets.corners().len(), 4, "no corner selected: every widget shows");
|
||||
let at = pts(&widgets)[0];
|
||||
let lc = LiveCorners::of(d.node(id).unwrap()).unwrap();
|
||||
let corner = *lc.all().iter().next().unwrap();
|
||||
let anchors: Vec<(usize, usize)> = lc.anchors_of(&[corner].into()).into_iter().collect();
|
||||
let shift = Mods { shift: true, ..Mods::default() };
|
||||
let mut t = crate::create("directSelection");
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y).with_mods(shift));
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, at.x, at.y).with_mods(shift));
|
||||
assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": anchors, "mode": "toggle"}))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_selected_corners_show_their_widgets_alone_and_round_alone() {
|
||||
let (d, id) = live_rect(0.0, Affine::translate((100.0, 100.0)));
|
||||
|
||||
@@ -89,6 +89,7 @@ impl VectorcraftApp {
|
||||
view_moved: None,
|
||||
hover_cursor: None,
|
||||
presented: None,
|
||||
opening_fit: None,
|
||||
panes: Default::default(),
|
||||
windows: crate::doc_windows::Windows::new(known),
|
||||
},
|
||||
|
||||
@@ -65,6 +65,10 @@ pub struct CanvasCache {
|
||||
/// In Presentation Mode, the canvas rect the active artboard was last fitted to: it is fitted
|
||||
/// again when the rect changes (the window going full screen).
|
||||
pub presented: Option<egui::Rect>,
|
||||
/// The canvas a document's opening view was fitted to, with the view the fit gave. The window's
|
||||
/// layout can still move the canvas's edges over the next frames, so until the view is moved or
|
||||
/// the user acts, the opening view is fitted again when the canvas changes ([`canvas::show`]).
|
||||
pub opening_fit: Option<canvas::ViewKey>,
|
||||
/// Window › Arrange › Tile: the renders the panes of the other documents draw.
|
||||
pub panes: canvas::Panes,
|
||||
/// Window › Arrange › Float in Window: what the floating document windows keep between frames.
|
||||
|
||||
@@ -186,6 +186,36 @@ fn view_rects(app: &VectorcraftApp, outer: egui::Rect, ppp: f32) -> (egui::Rect,
|
||||
(full, rect.round_to_pixels(ppp))
|
||||
}
|
||||
|
||||
/// Whether the user pressed a button or a key, typed, scrolled or zoomed in this frame: the
|
||||
/// opening fit stops following the canvas then ([`crate::caches::CanvasCache::opening_fit`]).
|
||||
fn acted(i: &egui::InputState) -> bool {
|
||||
i.pointer.any_pressed()
|
||||
|| i.events.iter().any(|e| {
|
||||
matches!(
|
||||
e,
|
||||
egui::Event::Key { pressed: true, .. }
|
||||
| egui::Event::Text(_)
|
||||
| egui::Event::MouseWheel { .. }
|
||||
| egui::Event::Zoom(_)
|
||||
| egui::Event::Touch { .. }
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// The view `v` shows in the canvas `rect`, as the opening fit records it.
|
||||
fn view_key(v: &View, rect: egui::Rect) -> ViewKey {
|
||||
ViewKey { rect, zoom: v.zoom, center: v.center, rotation: v.rotation }
|
||||
}
|
||||
|
||||
/// Whether the opening fit still follows the canvas `rect` (the view is unfitted, or unmoved since
|
||||
/// the fit and the user hasn't acted), and whether the canvas moved under it this frame, so the view
|
||||
/// is fitted again.
|
||||
fn opening_fit_follows(app: &VectorcraftApp, ui: &Ui, rect: egui::Rect, fitted: bool) -> (bool, bool) {
|
||||
let following = !fitted || (!ui.input(acted) && app.canvas.opening_fit.zip(app.view()).is_some_and(|(was, v)| view_key(v, was.rect) == was));
|
||||
let settling = fitted && following && app.canvas.opening_fit.is_some_and(|was| was.rect != rect);
|
||||
(following, settling)
|
||||
}
|
||||
|
||||
/// Paint the document canvas, complete or cached artwork, navigation and tool overlays.
|
||||
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
@@ -225,13 +255,19 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let (full, rect) = view_rects(app, outer, ui.ctx().pixels_per_point());
|
||||
app.canvas_rect = Some(rect);
|
||||
let fitted = app.view().is_some_and(|v| v.fitted);
|
||||
// A document's opening view is fitted to the canvas of the first frame that shows it, and the
|
||||
// window's layout can still move the canvas's edges over the next frames. Until the view is
|
||||
// moved or the user acts, the opening fit follows the canvas, so that the document opens at the
|
||||
// zoom of the settled window.
|
||||
let (following, settling) = opening_fit_follows(app, ui, rect, fitted);
|
||||
// Presentation Mode shows the active artboard on the whole screen and nothing but the art:
|
||||
// no selection, guides, grids or artboard edges (#4365).
|
||||
let presenting = app.ui.screen_mode == 3;
|
||||
if !fitted || (presenting && app.canvas.presented != Some(rect)) {
|
||||
if !fitted || settling || (presenting && app.canvas.presented != Some(rect)) {
|
||||
fit(app, "view.fitArtboard");
|
||||
}
|
||||
app.canvas.presented = presenting.then_some(rect);
|
||||
app.canvas.opening_fit = following.then(|| app.view().map(|v| view_key(v, rect))).flatten();
|
||||
// Before the canvas's own interaction, so the view the canvas draws is the one they moved to.
|
||||
if bars {
|
||||
scroll_bars(app, ui, rect, outer, &t);
|
||||
|
||||
@@ -87,6 +87,60 @@ fn subpixel_dock_layout_does_not_change_pointer_document_coordinates() {
|
||||
assert_ne!(observed[0], observed[2], "a real device-pixel resize must still change the mapping");
|
||||
}
|
||||
|
||||
/// A document's opening view is fitted to the canvas of the first frame that shows it, and the
|
||||
/// window's layout can still move the canvas's edges over the next frames. Two starts of the app
|
||||
/// then showed one file at zooms a hair apart, on canvases alike once the window had settled. So
|
||||
/// until the view is moved or the user presses, types or scrolls, the opening fit follows the
|
||||
/// canvas.
|
||||
#[test]
|
||||
fn the_opening_fit_follows_the_canvas_until_the_view_moves_or_the_user_acts() {
|
||||
// A frame that shows the canvas `width` points wide, which limits the fit of the 800 × 600
|
||||
// artboard, with `events`: the view after it.
|
||||
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, width: f32, events: Vec<egui::Event>) -> View {
|
||||
let raw = egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(1800.0, 900.0))),
|
||||
max_texture_side: Some(8192),
|
||||
events,
|
||||
..Default::default()
|
||||
};
|
||||
let mut out = ctx.run_ui(raw, |ui| {
|
||||
let area = egui::Rect::from_min_size(pos2(54.0, 72.0), vec2(width + SCROLL_BAR, 636.0 + SCROLL_BAR));
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(area));
|
||||
show(app, &mut child);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
*app.view().unwrap()
|
||||
}
|
||||
fn opened() -> (VectorcraftApp, egui::Context) {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 800, "height": 600, "created": 0})).unwrap();
|
||||
(app, egui::Context::default())
|
||||
}
|
||||
let first_shown_at = |width: f32| {
|
||||
let (mut app, ctx) = opened();
|
||||
frame(&mut app, &ctx, width, vec![]).zoom
|
||||
};
|
||||
let (narrow, wide) = (first_shown_at(700.0), first_shown_at(702.0));
|
||||
assert!(narrow < wide, "the canvas's width limits the fit: {narrow} and {wide}");
|
||||
let escape = || egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE };
|
||||
|
||||
// The first frame lays the canvas out 2 points narrower than the next: the document opens at
|
||||
// the zoom of the settled canvas, as it does when the first frame has it already.
|
||||
let (mut app, ctx) = opened();
|
||||
frame(&mut app, &ctx, 700.0, vec![]);
|
||||
assert_eq!(frame(&mut app, &ctx, 702.0, vec![]).zoom, wide);
|
||||
// Once the user has pressed a key, the canvas changing keeps the view, as resizing the window
|
||||
// does.
|
||||
assert_eq!(frame(&mut app, &ctx, 702.0, vec![escape()]).zoom, wide);
|
||||
assert_eq!(frame(&mut app, &ctx, 700.0, vec![]).zoom, wide);
|
||||
|
||||
// A view moved before the canvas changes stays where it was moved to.
|
||||
let (mut app, ctx) = opened();
|
||||
frame(&mut app, &ctx, 700.0, vec![]);
|
||||
app.view_mut().unwrap().zoom = 0.5;
|
||||
assert_eq!(frame(&mut app, &ctx, 702.0, vec![]).zoom, 0.5);
|
||||
}
|
||||
|
||||
/// A rendered raster becomes the image egui's own conversion makes of it, half-transparent
|
||||
/// edges and all; one whose bytes don't match its size becomes an empty image of that size.
|
||||
#[test]
|
||||
|
||||
@@ -101,7 +101,8 @@ pub(super) const SPEC: DialogSpec = DialogSpec {
|
||||
"section",
|
||||
]
|
||||
},
|
||||
min_width: 650.0,
|
||||
min_width: 720.0,
|
||||
max_width: Some(720.0),
|
||||
..DialogSpec::FORM
|
||||
};
|
||||
|
||||
@@ -885,40 +886,81 @@ pub(crate) fn opentype_body(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The key under which the dialog keeps its footer's height for the next frame: the dialog's own
|
||||
/// id, so the stored value is found on every frame.
|
||||
fn footer_id(ui: &egui::Ui) -> egui::Id {
|
||||
ui.layer_id().id.with("char-style-footer")
|
||||
}
|
||||
|
||||
/// The dialog's body: the section list beside the selected section's content, which scrolls when it
|
||||
/// is taller than the window (General lists every attribute of the style and OpenType every
|
||||
/// feature), and the footer below it - Reset Panel, the Preview checkbox and an invalid value's
|
||||
/// error. The content's height is the space the footer leaves, measured last frame, so the buttons
|
||||
/// stay on screen at any interface scale; the section list is outside the scroll area and stays put
|
||||
/// while the content scrolls. Returns whether the dialog asks to be cancelled (never: Cancel comes
|
||||
/// from the button row in `dialogs::show`).
|
||||
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
// The content scrolls inside a shorter window: General lists every attribute of the style and
|
||||
// OpenType every feature, so the window stays within the screen and its buttons stay visible.
|
||||
// The footer was measured last frame, so the room left for the content keeps it on screen at any
|
||||
// interface scale; the section list stays put while the content scrolls.
|
||||
let footer = ui.ctx().data(|m| m.get_temp::<f32>(footer_id(ui)).unwrap_or(120.0));
|
||||
let room = (ui.available_height() - footer - 8.0).clamp(0.0, 420.0);
|
||||
ui.horizontal(|ui| {
|
||||
section_list(ui, d);
|
||||
ui.separator();
|
||||
ui.vertical(|ui| {
|
||||
if d.str("section") == "basic" {
|
||||
basic_body(ui, d);
|
||||
} else if d.str("section") == "advanced" {
|
||||
advanced_body(ui, d);
|
||||
} else if d.str("section") == "color" {
|
||||
color_body(ui, d);
|
||||
} else if d.str("section") == "opentype" {
|
||||
opentype_body(ui, d);
|
||||
} else {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tl!("Style Name:"));
|
||||
form::text(ui, d, "name", 190.0);
|
||||
});
|
||||
ui.separator();
|
||||
if let Ok(attrs) = edited_attrs(d) {
|
||||
for (key, value) in attrs {
|
||||
ui.label(format!("{}: {}", super::humanize(&key), value));
|
||||
// The section list handles a click before this frame's content is laid out, so the section
|
||||
// read here is the one just picked: its own scroll offset is used from the first frame.
|
||||
let section = d.str("section");
|
||||
let content_w = ui.available_width();
|
||||
// Long sections scroll both ways: at a large interface scale the window has fewer points, and
|
||||
// a wide row (a character-format grid) must stay inside it rather than widen the window.
|
||||
egui::ScrollArea::both().id_salt(("char-style-sections", section)).max_height(room).max_width(content_w).auto_shrink([false, true]).show(
|
||||
ui,
|
||||
|ui| {
|
||||
ui.vertical(|ui| {
|
||||
if d.str("section") == "basic" {
|
||||
basic_body(ui, d);
|
||||
} else if d.str("section") == "advanced" {
|
||||
advanced_body(ui, d);
|
||||
} else if d.str("section") == "color" {
|
||||
color_body(ui, d);
|
||||
} else if d.str("section") == "opentype" {
|
||||
opentype_body(ui, d);
|
||||
} else {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tl!("Style Name:"));
|
||||
form::text(ui, d, "name", 190.0);
|
||||
});
|
||||
ui.separator();
|
||||
if let Ok(attrs) = edited_attrs(d) {
|
||||
for (key, value) in attrs {
|
||||
ui.label(format!("{}: {}", super::humanize(&key), value));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
reset_button(ui, d);
|
||||
match edited_attrs(d) {
|
||||
Ok(attrs) => {
|
||||
let params = json!({"name": d.str("__style"), "to": d.str("name"), "attrs": attrs});
|
||||
form::preview(app, ui, d, tl!("Character Style Options"), "charStyle.edit", params);
|
||||
}
|
||||
Err(why) => attrs_error(ui, &why),
|
||||
let footer_h = ui
|
||||
.scope(|ui| {
|
||||
reset_button(ui, d);
|
||||
match edited_attrs(d) {
|
||||
Ok(attrs) => {
|
||||
let params = json!({"name": d.str("__style"), "to": d.str("name"), "attrs": attrs});
|
||||
form::preview(app, ui, d, tl!("Character Style Options"), "charStyle.edit", params);
|
||||
}
|
||||
Err(why) => attrs_error(ui, &why),
|
||||
}
|
||||
})
|
||||
.response
|
||||
.rect
|
||||
.height();
|
||||
let was = ui.ctx().data(|m| m.get_temp::<f32>(footer_id(ui)));
|
||||
ui.ctx().data_mut(|m| m.insert_temp(footer_id(ui), footer_h));
|
||||
if was != Some(footer_h) {
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
false
|
||||
}
|
||||
@@ -952,6 +994,257 @@ mod tests {
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
/// The texts a frame drew, with their screen rects.
|
||||
fn texts_of(out: &egui::FullOutput) -> Vec<(String, egui::Rect)> {
|
||||
fn collect(s: &egui::Shape, out: &mut Vec<(String, egui::Rect)>) {
|
||||
match s {
|
||||
egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect())),
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| collect(s, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut texts = vec![];
|
||||
for c in &out.shapes {
|
||||
collect(&c.shape, &mut texts);
|
||||
}
|
||||
texts
|
||||
}
|
||||
|
||||
/// The texts of a few headless frames in `ctx` at a screen `w` by `h`, with their screen rects:
|
||||
/// the dialog lays its scroll area out and measures its footer over the first frames.
|
||||
fn frame_texts_in(ctx: &egui::Context, app: &mut VectorcraftApp, w: f32, h: f32) -> Vec<(String, egui::Rect)> {
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(w, h));
|
||||
let mut texts = vec![];
|
||||
for _ in 0..5 {
|
||||
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| super::super::show(app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
texts = texts_of(&out);
|
||||
}
|
||||
texts
|
||||
}
|
||||
|
||||
/// The texts of a few headless frames at a screen `w` by `h`, and the context they were drawn in.
|
||||
fn frame_texts_at(app: &mut VectorcraftApp, w: f32, h: f32) -> (egui::Context, Vec<(String, egui::Rect)>) {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let texts = frame_texts_in(&ctx, app, w, h);
|
||||
(ctx, texts)
|
||||
}
|
||||
|
||||
/// The General section lists every attribute of the style, so the sections scroll inside a
|
||||
/// window shorter than that list: the buttons stay on the screen, the window keeps its width in
|
||||
/// every section, and the content is clipped by the scroll area's viewport.
|
||||
#[test]
|
||||
fn the_sections_scroll_in_a_window_shorter_than_the_general_list() {
|
||||
let mut app = app();
|
||||
app.run(
|
||||
"charStyle.new",
|
||||
json!({"name": "Body", "attrs": {
|
||||
"font_family": "Source Sans 3", "font_style": "Regular", "size": 24.0, "leading": 30.0,
|
||||
"kerning": 0.0, "tracking": 5.0, "all_caps": false, "small_caps": null,
|
||||
"position": {"kind": "normal", "size": 100.0, "position": 0.0}, "underline": false,
|
||||
"strikethrough": false, "standardVerticalRoman": false, "charAlign": "romanBaseline",
|
||||
"h_scale": 100.0, "v_scale": 100.0, "baseline_shift": 0.0, "rotation": 0.0,
|
||||
"language": "English: USA", "overprint_fill": false, "overprint_stroke": false,
|
||||
"stroke_width": 0.0, "features": ["liga", "calt", "dlig", "swsh", "salt", "titl", "ordn", "frac"]
|
||||
}}),
|
||||
)
|
||||
.unwrap();
|
||||
open(&mut app, "Body").unwrap();
|
||||
let (ctx, texts) = frame_texts_at(&mut app, 900.0, 560.0);
|
||||
let in_screen = |label: &str| {
|
||||
let r = texts.iter().find(|(text, _)| text == label).unwrap_or_else(|| panic!("{label} is drawn")).1;
|
||||
r.top() >= 0.0 && r.bottom() <= 560.0 + 1.0
|
||||
};
|
||||
assert!(in_screen("OK") && in_screen("Cancel"), "the buttons stay on a 560-point screen");
|
||||
let dialog = egui::Id::unique(("dialog", "charStyleOptions"));
|
||||
let width = ctx.memory(|m| m.area_rect(dialog)).expect("the dialog is drawn").width();
|
||||
assert!((700.0..=820.0).contains(&width), "the dialog keeps its 720-point width: {width}");
|
||||
let reset = texts.iter().find(|(text, _)| text == "Reset Panel").expect("the footer is drawn").1.top();
|
||||
assert!(
|
||||
texts.iter().any(|(text, rect)| text.contains(':') && rect.bottom() > reset + 1.0),
|
||||
"the General list is taller than the scroll area and runs under the footer: {texts:?}"
|
||||
);
|
||||
// A short section uses the same width.
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("section".into(), json!("basic"));
|
||||
let _ = frame_texts_in(&ctx, &mut app, 900.0, 560.0);
|
||||
let basic = ctx.memory(|m| m.area_rect(dialog)).expect("the dialog is drawn").width();
|
||||
assert!((basic - width).abs() < 0.5, "the width does not change with the section: {width} -> {basic}");
|
||||
}
|
||||
/// The dialog stays usable in the smallest window the app allows (800 by 500 points): in every
|
||||
/// section the section list, OK and Cancel are on the screen.
|
||||
#[test]
|
||||
fn every_section_and_the_buttons_fit_the_smallest_window() {
|
||||
let mut app = app();
|
||||
app.run("charStyle.new", json!({"name": "Body"})).unwrap();
|
||||
open(&mut app, "Body").unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
const W: f32 = 800.0;
|
||||
const H: f32 = 500.0;
|
||||
let labels = ["General", "Basic Character Formats", "Advanced Character Formats", "Character Color", "OpenType Features", "OK", "Cancel"];
|
||||
for section in ["general", "basic", "advanced", "color", "opentype"] {
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("section".into(), json!(section));
|
||||
let texts = frame_texts_in(&ctx, &mut app, W, H);
|
||||
for label in labels {
|
||||
let r = texts.iter().find(|(text, _)| text == label).unwrap_or_else(|| panic!("{label} is drawn in {section}")).1;
|
||||
assert!(r.top() >= 0.0 && r.bottom() <= H + 1.0, "{label} stays on an 800 by 500 screen in {section}: {r:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// At 2x UI scale the smallest window (800 by 500 pixels) holds only 400 by 250 points: in
|
||||
/// every section the section list and both buttons stay on the screen.
|
||||
#[test]
|
||||
fn every_section_and_the_buttons_fit_the_smallest_window_at_twice_the_scale() {
|
||||
let mut app = app();
|
||||
app.run(
|
||||
"charStyle.new",
|
||||
json!({"name": "Body", "attrs": {
|
||||
"font_family": "Source Sans 3", "font_style": "Regular", "size": 24.0, "leading": 30.0,
|
||||
"kerning": 0.0, "tracking": 5.0, "all_caps": false, "small_caps": null,
|
||||
"position": {"kind": "normal", "size": 100.0, "position": 0.0}, "underline": false,
|
||||
"strikethrough": false, "standardVerticalRoman": false, "charAlign": "romanBaseline",
|
||||
"h_scale": 100.0, "v_scale": 100.0, "baseline_shift": 0.0, "rotation": 0.0,
|
||||
"language": "English: USA", "overprint_fill": false, "overprint_stroke": false,
|
||||
"stroke_width": 0.0, "features": ["liga", "calt", "dlig", "swsh", "salt", "titl", "ordn", "frac"]
|
||||
}}),
|
||||
)
|
||||
.unwrap();
|
||||
open(&mut app, "Body").unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
ctx.set_zoom_factor(2.0);
|
||||
const W: f32 = 400.0;
|
||||
const H: f32 = 250.0;
|
||||
let labels = ["General", "Basic Character Formats", "Advanced Character Formats", "Character Color", "OpenType Features", "OK", "Cancel"];
|
||||
for section in ["general", "basic", "advanced", "color", "opentype"] {
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("section".into(), json!(section));
|
||||
let texts = frame_texts_in(&ctx, &mut app, W, H);
|
||||
for label in labels {
|
||||
let r = texts.iter().find(|(text, _)| text == label).unwrap_or_else(|| panic!("{label} is drawn in {section}")).1;
|
||||
assert!(
|
||||
r.top() >= 0.0 && r.bottom() <= H + 1.0 && r.left() >= 0.0 && r.right() <= W + 1.0,
|
||||
"{label} stays on a 400 by 250 point screen at 2x in {section}: {r:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// A wheel over the content scrolls it; the section list and both buttons stay put.
|
||||
let at = egui::Pos2::new(300.0, 60.0);
|
||||
let mut after = texts.clone();
|
||||
for _ in 0..8 {
|
||||
let events = vec![
|
||||
egui::Event::PointerMoved(at),
|
||||
egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
delta: egui::vec2(-40.0, -40.0),
|
||||
phase: egui::TouchPhase::Move,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
},
|
||||
];
|
||||
let mut out = ctx.run_ui(
|
||||
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(W, H))), events, ..Default::default() },
|
||||
|ui| super::super::show(&mut app, ui.ctx()),
|
||||
);
|
||||
out.textures_delta.clear();
|
||||
after = texts_of(&out);
|
||||
}
|
||||
for label in labels {
|
||||
let r = after.iter().find(|(text, _)| text == label).unwrap_or_else(|| panic!("{label} is drawn in {section}")).1;
|
||||
assert!(r.top() >= 0.0 && r.bottom() <= H + 1.0, "{label} stays after scrolling in {section}: {r:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Switching from a scrolled General section to a shorter one and back keeps General's own
|
||||
/// scroll offset: the section is read after the list handles the click, so the click frame
|
||||
/// stores no offset for the section that is leaving.
|
||||
#[test]
|
||||
fn a_scrolled_section_keeps_its_offset_after_switching_away_and_back() {
|
||||
let mut app = app();
|
||||
app.run(
|
||||
"charStyle.new",
|
||||
json!({"name": "Body", "attrs": {
|
||||
"font_family": "Source Sans 3", "font_style": "Regular", "size": 24.0, "leading": 30.0,
|
||||
"kerning": 0.0, "tracking": 5.0, "all_caps": false, "small_caps": null,
|
||||
"position": {"kind": "normal", "size": 100.0, "position": 0.0}, "underline": false,
|
||||
"strikethrough": false, "standardVerticalRoman": false, "charAlign": "romanBaseline",
|
||||
"h_scale": 100.0, "v_scale": 100.0, "baseline_shift": 0.0, "rotation": 0.0,
|
||||
"language": "English: USA", "overprint_fill": false, "overprint_stroke": false,
|
||||
"stroke_width": 0.0, "features": ["liga", "calt", "dlig", "swsh", "salt", "titl", "ordn", "frac"]
|
||||
}}),
|
||||
)
|
||||
.unwrap();
|
||||
open(&mut app, "Body").unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 560.0));
|
||||
let frame = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
|
||||
let mut out =
|
||||
ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| super::super::show(app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
texts_of(&out)
|
||||
};
|
||||
let mut texts = vec![];
|
||||
for _ in 0..5 {
|
||||
texts = frame(&mut app, vec![]);
|
||||
}
|
||||
let y = |texts: &[(String, egui::Rect)], label: &str| {
|
||||
texts.iter().find(|(text, _)| text == label).unwrap_or_else(|| panic!("{label} is drawn")).1.top()
|
||||
};
|
||||
eprintln!("DEBUG initial texts: {texts:?}");
|
||||
let general_top = y(&texts, "Style Name:");
|
||||
let before = texts.clone();
|
||||
// Scroll General down with the wheel.
|
||||
let at = egui::Pos2::new(600.0, 200.0);
|
||||
for _ in 0..8 {
|
||||
let events = vec![
|
||||
egui::Event::PointerMoved(at),
|
||||
egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
delta: egui::vec2(0.0, -40.0),
|
||||
phase: egui::TouchPhase::Move,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
},
|
||||
];
|
||||
texts = frame(&mut app, events);
|
||||
}
|
||||
// Let the smooth wheel scroll finish before changing sections.
|
||||
for _ in 0..15 {
|
||||
texts = frame(&mut app, vec![]);
|
||||
}
|
||||
let scrolled = texts.clone();
|
||||
assert!(
|
||||
before.iter().any(|(label, _)| !scrolled.iter().any(|(other, _)| other == label))
|
||||
&& scrolled.iter().any(|(label, _)| !before.iter().any(|(other, _)| other == label)),
|
||||
"General scrolls to other attributes"
|
||||
);
|
||||
// Click Basic Character Formats, then General.
|
||||
let click = |app: &mut VectorcraftApp, label: &str| {
|
||||
let texts = frame(app, vec![]);
|
||||
let pos = texts.iter().find(|(text, _)| text == label).unwrap_or_else(|| panic!("{label} is drawn")).1.center();
|
||||
let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
for e in [egui::Event::PointerMoved(pos), button(true), button(false)] {
|
||||
let _ = frame(app, vec![e]);
|
||||
}
|
||||
let _ = frame(app, vec![]);
|
||||
};
|
||||
click(&mut app, "Basic Character Formats");
|
||||
let basic = frame(&mut app, vec![]);
|
||||
assert!(y(&basic, "Font Family:") < general_top + 40.0, "a short section opens at its top");
|
||||
click(&mut app, "General");
|
||||
let back = frame(&mut app, vec![]);
|
||||
let mut same = 0;
|
||||
for (label, old) in &scrolled {
|
||||
if let Some((_, new)) = back.iter().find(|(text, _)| text == label) {
|
||||
assert!((new.top() - old.top()).abs() < 1.0, "General keeps its offset at {label}: {old:?} -> {new:?}");
|
||||
same += 1;
|
||||
}
|
||||
}
|
||||
assert!(same > 0, "General draws its content again: {back:?}");
|
||||
}
|
||||
#[test]
|
||||
fn unchanged_basic_fields_preserve_original_keys() {
|
||||
for attrs in [
|
||||
|
||||
@@ -114,17 +114,19 @@ pub(super) fn show_preview(ui: &mut egui::Ui, tex: Option<&egui::TextureHandle>,
|
||||
|
||||
/// Open `bytes` (or place them with the `file.place` params `place`) through the dialog when the
|
||||
/// file is a PDF (or `.ai`/`.ait`) with several pages or a password; false when there's nothing to
|
||||
/// ask (the caller goes on), including a file that carries its VectorCraft document when opening.
|
||||
/// ask (the caller goes on), including, when opening, a file that carries its VectorCraft document
|
||||
/// and a `.ai`/`.ait` with its editing data.
|
||||
pub fn offer(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option<String>, place: Option<&Value>) -> bool {
|
||||
if !fileio::detect(name, bytes).is_some_and(|f| matches!(f.id, "pdf" | "ai" | "ait")) {
|
||||
let Some(format) = fileio::detect(name, bytes).map(|f| f.id).filter(|id| matches!(*id, "pdf" | "ai" | "ait")) else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
// A file carrying its VectorCraft document (a saved .ai, or a PDF saved with Preserve Editing)
|
||||
// opens as that document, every artboard and layer: there are no pages to pick. Placing still
|
||||
// asks, since a place takes one page.
|
||||
// opens as that document, every artboard and layer: there are no pages to pick. So does a .ai
|
||||
// or .ait with its editing data, which holds them all (#6135); a PDF with editing data still
|
||||
// asks, as its pages are what it is. Placing still asks, since a place takes one page.
|
||||
// The file is read once for both checks.
|
||||
let opened = vectorcraft_pdf::open_pdf(bytes, None);
|
||||
if place.is_none() && opened.as_ref().is_ok_and(|o| o.editing().is_some()) {
|
||||
if place.is_none() && opened.as_ref().is_ok_and(|o| o.editing().is_some() || (format != "pdf" && o.has_illustrator_data())) {
|
||||
return false;
|
||||
}
|
||||
let (pages, locked) = match opened.map(|o| o.page_count()) {
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::NodeKind;
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_testkit::pdf::{PdfPage, pdf};
|
||||
use vectorcraft_testkit::ai;
|
||||
use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf, pdf_with};
|
||||
|
||||
use super::*;
|
||||
use crate::io;
|
||||
@@ -197,3 +198,72 @@ fn placing_a_saved_ai_still_asks_for_the_page() {
|
||||
assert!(import_pdf::offer(&mut app, "two.ai", &bytes, None, Some(&json!({}))));
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().str("mode"), "place");
|
||||
}
|
||||
|
||||
/// Two pages, 100 and 200 pt wide; with `editing_data` the first page has the keys of an editor's
|
||||
/// copy of the art.
|
||||
fn two_pages(editing_data: bool, password: Option<&str>) -> Vec<u8> {
|
||||
let mut first = PdfPage::new(100.0, 100.0, "0 g 0 0 10 10 re f");
|
||||
if editing_data {
|
||||
first.entries = "/PieceInfo << /Illustrator << /Private << /AIPrivateData1 7 /NumBlock 1 >> >> >>".into();
|
||||
}
|
||||
pdf(&[first, PdfPage::new(200.0, 100.0, "1 0 0 rg 0 0 30 30 re f")], password)
|
||||
}
|
||||
|
||||
/// Two 100 pt pages and, on the first, editing data with two artboards to match and a layer of its
|
||||
/// own, which draws the square the first page draws.
|
||||
fn two_pages_with_a_layer_in_the_editing_data(layer: &str) -> Vec<u8> {
|
||||
let point = |x: i32, y: i32, n: u8| format!("%_{x} {y} /RealPointRelToROrigin\n%_ (PositionPoint{n}) ,\n");
|
||||
let board = |top: i32| format!("%_/Dictionary :\n{}{}%_; ,\n", point(0, top, 1), point(100, top - 100, 2));
|
||||
let boards = format!("%_/Document :\n%_/Dictionary :\n%_/Array :\n{}{}%_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n", board(100), board(-50));
|
||||
let art = format!("0 0 0 1 k\n{}f\n", ai::rect(10.0, 10.0, 30.0, 30.0));
|
||||
let data = ai::editing_data(100.0, 100.0, &format!("{boards}{}", ai::layer(layer, &art)));
|
||||
let mut first = PdfPage::new(100.0, 100.0, "0 0 0 1 k 10 10 20 20 re f");
|
||||
first.entries = format!("/PieceInfo << /Illustrator << /Private << /AIPrivateData1 {} 0 R /NumBlock 1 >> >> >>", first_extra(2));
|
||||
let stream = format!("<< /Length {} >>\nstream\n{data}\nendstream", data.len());
|
||||
pdf_with(&[first, PdfPage::new(100.0, 100.0, "")], &[&stream], None)
|
||||
}
|
||||
|
||||
/// #6135: the editing data of a `.ai` holds every artboard, so opening one asks nothing, opens
|
||||
/// them all and reads the art from that data: the layer is in nothing else.
|
||||
#[test]
|
||||
fn opening_an_ai_with_editing_data_reads_it_without_asking() {
|
||||
let mut app = app();
|
||||
let bytes = two_pages_with_a_layer_in_the_editing_data("Only in the editing data");
|
||||
io::open_bytes(&mut app, "two.ai", &bytes, Some("/tmp/two.ai".into())).unwrap();
|
||||
assert!(app.ui.dialog.is_none(), "no Import PDF dialog");
|
||||
assert_eq!(app.session.documents().len(), 2);
|
||||
assert_eq!(artboard_widths(&app), [100.0, 100.0]);
|
||||
let layers = &app.session.active().unwrap().doc.layers;
|
||||
assert!(layers.iter().any(|l| l.name.as_deref() == Some("Only in the editing data")), "read from the editing data");
|
||||
assert!(!import_pdf::offer(&mut app, "two.ait", &bytes, None, None), "a template too");
|
||||
assert!(app.ui.dialog.is_none());
|
||||
}
|
||||
|
||||
/// A `.ai` whose editing data can't be read asks nothing either, and every page opens from the PDF
|
||||
/// part. Asking at that point is #6134.
|
||||
#[test]
|
||||
fn opening_an_ai_with_unreadable_editing_data_opens_every_page_without_asking() {
|
||||
let mut app = app();
|
||||
io::open_bytes(&mut app, "two.ai", &two_pages(true, None), Some("/tmp/two.ai".into())).unwrap();
|
||||
assert!(app.ui.dialog.is_none(), "no Import PDF dialog");
|
||||
assert_eq!(artboard_widths(&app), [100.0, 200.0]);
|
||||
}
|
||||
|
||||
/// #6135: the dialog stays for what still has something to ask: a PDF, even one with editing data
|
||||
/// keys, a `.ai` that is only a PDF, a password, and Place.
|
||||
#[test]
|
||||
fn the_import_dialog_stays_where_there_is_something_to_ask() {
|
||||
for (name, bytes) in [("two.pdf", two_pages(true, None)), ("two.ai", two_pages(false, None))] {
|
||||
let mut app = app();
|
||||
assert!(import_pdf::offer(&mut app, name, &bytes, None, None), "{name}");
|
||||
assert_eq!((app.ui.dialog.as_ref().unwrap().str("mode"), field(&app, "pageCount")), ("open".into(), json!(2)), "{name}");
|
||||
}
|
||||
|
||||
let mut app = app();
|
||||
assert!(import_pdf::offer(&mut app, "locked.ai", &two_pages(true, Some("pw")), None, None));
|
||||
assert_eq!(field(&app, "__locked"), true);
|
||||
|
||||
let mut app = self::app();
|
||||
assert!(import_pdf::offer(&mut app, "two.ai", &two_pages(true, None), None, Some(&json!({}))));
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().str("mode"), "place");
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Type → Find Font…: the fonts the document uses (missing ones marked), Find (select the text
|
||||
//! using a font), Change / Change All to another font from the list Replace With Font From shows
|
||||
//! (the document's fonts, the recent fonts or every installed font), and Find in Folder… (the
|
||||
//! missing fonts' files, in the Missing Fonts dialog: `ui.findFontsInFolder`).
|
||||
//! missing fonts' files, in the Missing Fonts dialog: `ui.findFontsInFolder`). Find In limits Find
|
||||
//! to the active artboard or the selected layers.
|
||||
|
||||
use egui::Ui;
|
||||
use serde_json::{Value, json};
|
||||
@@ -23,9 +24,10 @@ pub fn open_on_missing(app: &mut VectorcraftApp) {
|
||||
open_at(app, first.unwrap_or(0));
|
||||
}
|
||||
|
||||
/// Open the dialog with the document's font `selected` (an index into `text.fonts`) chosen, and
|
||||
/// System's font ([`system_family`]) to change to.
|
||||
/// Open the dialog with the document's font `selected` (an index into `text.fonts`) chosen,
|
||||
/// System's font ([`system_family`]) to change to, and the last Find In choice (Document at first).
|
||||
fn open_at(app: &mut VectorcraftApp, selected: usize) {
|
||||
let find_in = FIND_IN.iter().find(|f| **f == app.ui.find_font_in).copied().unwrap_or("document");
|
||||
let used = fonts(app);
|
||||
let source = "system";
|
||||
let rows = replacement_rows(app, source, &used);
|
||||
@@ -33,7 +35,7 @@ fn open_at(app: &mut VectorcraftApp, selected: usize) {
|
||||
let style = rows.iter().find(|r| r.family.eq_ignore_ascii_case(&family)).map(|r| r.style.clone()).unwrap_or_default();
|
||||
app.ui.dialog = Some(Dialog::new(
|
||||
"findFont",
|
||||
json!({ "selected": selected, "family": family, "style": style, "selectionOnly": false, "replaceFrom": source, "rows": rows_json(&rows) }),
|
||||
json!({ "selected": selected, "family": family, "style": style, "selectionOnly": false, "replaceFrom": source, "rows": rows_json(&rows), "findIn": find_in }),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -210,6 +212,42 @@ fn set_source(app: &VectorcraftApp, d: &mut Dialog, source: &str, used: &[Value]
|
||||
d.fields.insert("style".into(), json!(pick.map(|r| r.style.clone()).unwrap_or_default()));
|
||||
}
|
||||
|
||||
/// Find In's choices (the `findIn` field): the whole document, the active artboard, or the layers
|
||||
/// highlighted in the Layers panel (else the current layer).
|
||||
const FIND_IN: [&str; 3] = ["document", "artboard", "layers"];
|
||||
const FIND_IN_LABELS: [&str; 3] = ["Document", "Artboard", "Layers"];
|
||||
|
||||
/// The `scope` Find passes `select.font` for Find In choice `find_in`. A document without
|
||||
/// artboards, or without a layer to look in, is looked through whole.
|
||||
fn find_scope(app: &VectorcraftApp, find_in: &str) -> Value {
|
||||
let Some(st) = app.session.active() else { return json!("document") };
|
||||
let n = st.doc.artboards.len();
|
||||
match find_in {
|
||||
"artboard" if n > 0 => json!({ "artboards": [crate::panels::artboards::selected(app, n)] }),
|
||||
"layers" => {
|
||||
let is_layer = |id: &vectorcraft_doc::NodeId| st.doc.node(*id).is_some_and(vectorcraft_doc::Node::is_layer);
|
||||
let mut layers: Vec<u64> = st.highlighted_rows().into_iter().filter(is_layer).map(|id| id.0).collect();
|
||||
if layers.is_empty() {
|
||||
layers.extend(st.current_layer().map(|id| id.0));
|
||||
}
|
||||
if layers.is_empty() { json!("document") } else { json!({ "layers": layers }) }
|
||||
}
|
||||
_ => json!("document"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the dialog's button `button` (find, change or changeAll) on the document font `from`.
|
||||
fn run_button(app: &mut VectorcraftApp, d: &Dialog, button: &str, from: &Value) -> Result<Value, String> {
|
||||
match button {
|
||||
"find" => {
|
||||
let scope = find_scope(app, &d.str("findIn"));
|
||||
app.run("select.font", json!({ "family": from["family"], "style": from["style"], "scope": scope }))
|
||||
}
|
||||
"change" => change(app, d, from, false),
|
||||
_ => change(app, d, from, true),
|
||||
}
|
||||
}
|
||||
|
||||
fn fonts(app: &mut VectorcraftApp) -> Vec<Value> {
|
||||
app.session.execute("text.fonts", &json!({})).ok().and_then(|v| v.as_array().cloned()).unwrap_or_default()
|
||||
}
|
||||
@@ -342,6 +380,14 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
ui.add_space(14.0);
|
||||
ui.horizontal(|ui| {
|
||||
widgets::field_label(ui, tl!("Find In"));
|
||||
let at = FIND_IN.iter().position(|s| *s == d.str("findIn")).unwrap_or(0);
|
||||
if let Some(i) = widgets::dropdown_named(ui, "ff-find-in", Some(tl!("Find In")), FIND_IN_LABELS[at], &FIND_IN_LABELS, 120.0) {
|
||||
d.fields.insert("findIn".into(), json!(FIND_IN[i]));
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
// An empty list (Recent before any font was applied) leaves nothing to change to.
|
||||
let replace = sel < list.len() && can_replace(&d, families.as_deref());
|
||||
ui.horizontal(|ui| {
|
||||
@@ -362,14 +408,10 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
});
|
||||
});
|
||||
});
|
||||
app.ui.find_font_in = d.str("findIn");
|
||||
let sel = d.fields.get("selected").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
if let (Some(a), Some(from)) = (act, list.get(sel)) {
|
||||
let r = match a {
|
||||
"find" => app.run("select.font", json!({ "family": from["family"], "style": from["style"] })),
|
||||
"change" => change(app, &d, from, false),
|
||||
_ => change(app, &d, from, true),
|
||||
};
|
||||
match r {
|
||||
match run_button(app, &d, a, from) {
|
||||
// The rows are a snapshot: the document's fonts and the recent fonts have moved on.
|
||||
Ok(_) if a != "find" => {
|
||||
let used = fonts(app);
|
||||
@@ -433,6 +475,48 @@ mod tests {
|
||||
assert_eq!(fonts(&mut app)[0]["family"], "Source Sans 3");
|
||||
}
|
||||
|
||||
/// Find In passes Find the active artboard, or the selected layers (else the current
|
||||
/// layer), as `select.font`'s scope.
|
||||
#[test]
|
||||
fn find_in_limits_find_to_the_artboard_or_the_layers() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
app.session.execute("artboard.new", &json!({"x": 200, "y": 0, "width": 100, "height": 100})).unwrap();
|
||||
let text = |app: &mut VectorcraftApp, x: i32| {
|
||||
app.session.execute("text.create", &json!({"x": x, "y": 40, "text": "Hi", "font": "Inter"})).unwrap()["id"].as_u64().unwrap()
|
||||
};
|
||||
let first = text(&mut app, 10);
|
||||
let layer = app.session.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
let second = text(&mut app, 210);
|
||||
let third = text(&mut app, 20);
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
// Choose Find In on the open dialog, draw a frame, then press Find.
|
||||
let find = |app: &mut VectorcraftApp, find_in: &str| {
|
||||
open(app);
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("findIn".into(), json!(find_in));
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
let d = app.ui.dialog.clone().unwrap();
|
||||
let from = fonts(app).into_iter().find(|f| f["family"] == "Inter").unwrap();
|
||||
run_button(app, &d, "find", &from).unwrap();
|
||||
let mut ids: Vec<u64> = app.session.active().unwrap().selection.objects.iter().map(|id| id.0).collect();
|
||||
ids.sort_unstable();
|
||||
ids
|
||||
};
|
||||
open(&mut app);
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().str("findIn"), "document");
|
||||
assert_eq!(find(&mut app, "document"), [first, second, third]);
|
||||
// Without a view, the active artboard is the first.
|
||||
assert_eq!(find(&mut app, "artboard"), [first, third]);
|
||||
assert_eq!(find_scope(&app, "layers"), json!({ "layers": [layer] }));
|
||||
assert_eq!(find(&mut app, "layers"), [second, third]);
|
||||
// Closing and opening the dialog again keeps the last choice.
|
||||
app.ui.dialog = None;
|
||||
open(&mut app);
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().str("findIn"), "layers");
|
||||
}
|
||||
|
||||
/// Change All leaves locked text in its font, and the status bar says so.
|
||||
#[test]
|
||||
fn change_all_clears_the_skip_warning_after_unlocking_text() {
|
||||
|
||||
@@ -2699,6 +2699,7 @@ digits Malayalam Малаялам
|
||||
Reset Transformation Нулиране на трансформацията
|
||||
Replace With Font Замяна с шрифт
|
||||
Replace With Font From Замяна с шрифт от
|
||||
Find In Търсене в
|
||||
System Система
|
||||
Replaces the saved preset of that name. Заменя записаната предварителна настройка с това име.
|
||||
Replace… Замяна…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4000,6 +4000,7 @@ effect Regular Obično
|
||||
Find in Folder… Pronađi u folderu…
|
||||
Replace With Font Zamijeni fontom
|
||||
Replace With Font From Zamijeni fontom iz
|
||||
Find In Traži u
|
||||
System Sistem
|
||||
— substituted by {style} — zamijenjeno stilom {style}
|
||||
— {n} characters from another font — {n} znakova iz drugog fonta
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3406,6 +3406,7 @@ digits Malayalam Malaiàlam
|
||||
Reset Transformation Restableix la transformació
|
||||
Replace With Font Reemplaça per un tipus de lletra
|
||||
Replace With Font From Reemplaça per un tipus de lletra de
|
||||
Find In Cerca a
|
||||
System Sistema
|
||||
Replace “{name}”, the file this document was opened from? Voleu reemplaçar “{name}”, el fitxer amb què es va obrir aquest document?
|
||||
Replaces the saved preset of that name. Reemplaça el preajust desat amb aquest nom.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4050,6 +4050,7 @@ effect Regular Pravidelný
|
||||
Find in Folder… Najít ve složce…
|
||||
Replace With Font Nahradit písmem
|
||||
Replace With Font From Nahradit písmem z
|
||||
Find In Hledat v
|
||||
System Systém
|
||||
— substituted by {style} — nahrazeno řezem {style}
|
||||
— {n} characters from another font — znaky z jiného písma: {n}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3766,6 +3766,7 @@ effect Regular Regular
|
||||
Find in Folder… Find i mappe…
|
||||
Replace With Font Erstat med skrifttype
|
||||
Replace With Font From Erstat med skrifttype fra
|
||||
Find In Søg i
|
||||
System System
|
||||
— substituted by {style} — erstattet af {style}
|
||||
— {n} characters from another font — {n} tegn fra en anden skrifttype
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2731,6 +2731,7 @@ digits Malayalam Malayalam
|
||||
Reset Transformation Transformation zurücksetzen
|
||||
Replace With Font Durch Schrift ersetzen
|
||||
Replace With Font From Ersetzende Schrift aus
|
||||
Find In Suchen in
|
||||
System System
|
||||
Replaces the saved preset of that name. Ersetzt die gespeicherte Vorgabe dieses Namens.
|
||||
Replace… Ersetzen…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3341,6 +3341,7 @@ digits Malayalam Μαλαγιάλαμ
|
||||
Reset Transformation Επαναφορά μετασχηματισμού
|
||||
Replace With Font Αντικατάσταση με γραμματοσειρά
|
||||
Replace With Font From Αντικατάσταση με γραμματοσειρά από
|
||||
Find In Αναζήτηση σε
|
||||
System Σύστημα
|
||||
Replaces the saved preset of that name. Αντικαθιστά την αποθηκευμένη προεπιλογή με αυτό το όνομα.
|
||||
Require a password to open the document Απαίτηση κωδικού για το άνοιγμα εγγράφου
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 168 and column 5.
|
@@ -2774,6 +2774,7 @@ digits Malayalam Malayálam
|
||||
Reset Transformation Restablecer transformación
|
||||
Replace With Font Reemplazar con fuente
|
||||
Replace With Font From Reemplazar con fuente de
|
||||
Find In Buscar en
|
||||
System Sistema
|
||||
Replaces the saved preset of that name. Reemplaza el preajuste guardado con ese nombre.
|
||||
Replace… Reemplazar…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3969,6 +3969,7 @@ effect Regular Normaali
|
||||
Find in Folder… Etsi kansiosta…
|
||||
Replace With Font Korvaa fontilla
|
||||
Replace With Font From Korvaavan fontin lähde
|
||||
Find In Etsi kohteesta
|
||||
System Järjestelmä
|
||||
— substituted by {style} — korvattu tyylillä {style}
|
||||
— {n} characters from another font — {n} merkkiä toisesta fontista
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2766,6 +2766,7 @@ digits Malayalam Malayalam
|
||||
Reset Transformation Réinitialiser la transformation
|
||||
Replace With Font Remplacer par la police
|
||||
Replace With Font From Remplacer par une police de
|
||||
Find In Rechercher dans
|
||||
System Système
|
||||
Replaces the saved preset of that name. Remplace le paramètre prédéfini enregistré sous ce nom.
|
||||
Replace… Remplacer…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3306,6 +3306,7 @@ mark-style Japanese Japán
|
||||
Reset Transformation Átalakítás visszaállítása
|
||||
Replace With Font Helyettesítő betűtípus
|
||||
Replace With Font From Helyettesítő betűtípus forrása
|
||||
Find In Keresés helye
|
||||
System Rendszer
|
||||
Replace “{name}”, the file this document was opened from? Felülírod ezt a fájlt, amelyből a dokumentumot megnyitottad: „{name}”?
|
||||
Replaces the saved preset of that name. Felülírja az azonos nevű mentett előbeállítást.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3766,6 +3766,7 @@ effect Regular Reguler
|
||||
Find in Folder… Temukan di folder…
|
||||
Replace With Font Ganti dengan font
|
||||
Replace With Font From Ganti dengan font dari
|
||||
Find In Cari di
|
||||
System Sistem
|
||||
— substituted by {style} — digantikan oleh {style}
|
||||
— {n} characters from another font — {n} karakter dari font lain
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2270 and column 5.
|
@@ -2807,6 +2807,7 @@ digits Malayalam Malayalam
|
||||
Reset Transformation Reimposta trasformazione
|
||||
Replace With Font Sostituisci con font
|
||||
Replace With Font From Sostituisci con font da
|
||||
Find In Cerca in
|
||||
System Sistema
|
||||
Replaces the saved preset of that name. Sostituisce il preset salvato con quel nome.
|
||||
Replace… Sostituisci…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2793,6 +2793,7 @@ mark-style Japanese 日本式
|
||||
Reset Transformation 変形をリセット
|
||||
Replace With Font 置換するフォント
|
||||
Replace With Font From 置換するフォントの一覧
|
||||
Find In 検索範囲
|
||||
System システム
|
||||
Replace it with another file, or ignore it: its images keep showing the preview saved with the document. 別のファイルに置き換えるか、無視してください。無視した場合、画像にはドキュメントに保存されたプレビューが表示され続けます。
|
||||
Replaces the saved preset of that name. 同じ名前の保存済みプリセットを置き換えます。
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1330,6 +1330,7 @@ lighting Light სინათლე
|
||||
Arabic არაბული
|
||||
Hindi ჰინდი
|
||||
Farsi სპარსული
|
||||
digits Hindi ჰინდი
|
||||
digits Devanagari დევანაგარი
|
||||
digits Bengali ბენგალური
|
||||
digits Gurmukhi გურმუხი
|
||||
@@ -3559,6 +3560,7 @@ effect Regular ჩვეულებრივი
|
||||
Find in Folder… საქაღალდეში ძებნა…
|
||||
Replace With Font ჩანაცვლება შრიფტით
|
||||
Replace With Font From ჩანაცვლების შრიფტის წყარო
|
||||
Find In ძებნის არე
|
||||
System სისტემა
|
||||
— substituted by {style} — ჩანაცვლებულია: {style}
|
||||
— {n} characters from another font — სხვა შრიფტის სიმბოლოები: {n}
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -2782,6 +2782,7 @@ mark-style Japanese 일본식
|
||||
Reset Transformation 변형 재설정
|
||||
Replace With Font 바꿀 글꼴
|
||||
Replace With Font From 바꿀 글꼴 목록
|
||||
Find In 찾을 위치
|
||||
System 시스템
|
||||
Replace it with another file, or ignore it: its images keep showing the preview saved with the document. 다른 파일로 바꾸거나 무시하세요. 무시하면 해당 이미지는 문서에 저장된 미리 보기로 계속 표시됩니다.
|
||||
Replaces the saved preset of that name. 같은 이름으로 저장된 사전 설정을 대체합니다.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3780,6 +3780,7 @@ effect Regular Regelmatig
|
||||
Find in Folder… Zoeken in map…
|
||||
Replace With Font Vervangen door lettertype
|
||||
Replace With Font From Vervangen door lettertype uit
|
||||
Find In Zoeken in
|
||||
System Systeem
|
||||
— substituted by {style} — vervangen door {style}
|
||||
— {n} characters from another font — {n} tekens van een ander lettertype
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3889,6 +3889,7 @@ effect Regular Normalny
|
||||
Find in Folder… Znajdź w folderze…
|
||||
Replace With Font Zastąp czcionką
|
||||
Replace With Font From Zastąp czcionką z
|
||||
Find In Szukaj w
|
||||
System System
|
||||
— substituted by {style} — zastąpiona przez {style}
|
||||
— {n} characters from another font — znaki z innej czcionki: {n}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2314,6 +2314,7 @@ digits Malayalam Malaiala
|
||||
Reset Transformation Redefinir Transformação
|
||||
Replace With Font Substituir Com Fonte
|
||||
Replace With Font From Substituir Com Fonte De
|
||||
Find In Localizar em
|
||||
System Sistema
|
||||
Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Substitua por outro arquivo, ou ignore-o: suas imagens continuam mostrando a pré-visualização salva com o documento.
|
||||
Replaces the saved preset of that name. Substitui o predefinido salvo com esse nome.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2802,6 +2802,7 @@ digits Malayalam Малаялам
|
||||
Reset Transformation Сбросить трансформацию
|
||||
Replace With Font Заменить шрифтом
|
||||
Replace With Font From Заменить шрифтом из
|
||||
Find In Искать в
|
||||
System Система
|
||||
Replaces the saved preset of that name. Заменяет сохранённый набор с таким именем.
|
||||
Replace… Заменить…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2710,6 +2710,7 @@ digits Malayalam Malayalam
|
||||
Reset Transformation Återställ transformering
|
||||
Replace With Font Ersätt med typsnitt
|
||||
Replace With Font From Ersätt med typsnitt från
|
||||
Find In Sök i
|
||||
System System
|
||||
Replaces the saved preset of that name. Ersätter den sparade förinställningen med samma namn.
|
||||
Replace… Ersätt…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -2579,6 +2579,7 @@ digits Malayalam Malayalam
|
||||
Reset Transformation Dönüşümü Sıfırla
|
||||
Replace With Font Yazı Tipiyle Değiştir
|
||||
Replace With Font From Değiştirilecek Yazı Tipi Kaynağı
|
||||
Find In Arama yeri
|
||||
System Sistem
|
||||
Replaces the saved preset of that name. Bu adın kayıtlı ön ayarını değiştirir.
|
||||
Replace… Değiştir…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -2799,6 +2799,7 @@ digits Malayalam Малаялам
|
||||
Reset Transformation Скинути трансформацію
|
||||
Replace With Font Замінити шрифтом
|
||||
Replace With Font From Замінити шрифтом з
|
||||
Find In Шукати в
|
||||
System Система
|
||||
Replaces the saved preset of that name. Замінює збережений набір із таким ім’ям.
|
||||
Replace… Замінити…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 182 and column 5.
|
@@ -3074,6 +3074,7 @@ mark-style Japanese Kiểu Nhật
|
||||
Reset Transformation Đặt lại biến đổi
|
||||
Replace With Font Thay thế bằng phông chữ
|
||||
Replace With Font From Thay thế bằng phông chữ từ
|
||||
Find In Tìm trong
|
||||
System Hệ thống
|
||||
Replace “{name}”, the file this document was opened from? Thay thế “{name}”, tệp mà tài liệu này được mở từ đó?
|
||||
Replaces the saved preset of that name. Thay thế thiết lập sẵn đã lưu có tên đó.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 204 and column 5.
|
@@ -2677,6 +2677,7 @@ digits Malayalam 马拉雅拉姆数字
|
||||
Reset Transformation 重置变换
|
||||
Replace With Font 替换为字体
|
||||
Replace With Font From 替换字体来源
|
||||
Find In 查找范围
|
||||
System 系统
|
||||
Replaces the saved preset of that name. 替换同名已存预设。
|
||||
Replace… 替换…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3680,6 +3680,7 @@ digits Malayalam 馬來亞拉姆數字
|
||||
Reset Transformation 重設變形
|
||||
Replace With Font 取代為字體
|
||||
Replace With Font From 取代字體來源
|
||||
Find In 尋找範圍
|
||||
System 系統
|
||||
Replaces the saved preset of that name. 取代同名的已儲存預設集。
|
||||
Replace… 取代…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -106,13 +106,14 @@ fn app(fill: Value) -> VectorcraftApp {
|
||||
struct Panel {
|
||||
ctx: egui::Context,
|
||||
time: f64,
|
||||
scope: egui::Id,
|
||||
}
|
||||
|
||||
impl Panel {
|
||||
fn new() -> Self {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
Self { ctx, time: 0.0 }
|
||||
Self { ctx, time: 0.0, scope: egui::Id::NULL }
|
||||
}
|
||||
|
||||
/// One frame with `events` (and `modifiers` held); returns the texts drawn and where.
|
||||
@@ -144,23 +145,27 @@ impl Panel {
|
||||
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(260.0, 600.0))),
|
||||
..Default::default()
|
||||
};
|
||||
let mut out = self.ctx.run_ui(input, |ui| draw(app, ui));
|
||||
let mut out = self.ctx.run_ui(input, |ui| {
|
||||
self.scope = ui.scope_id();
|
||||
draw(app, ui);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
let mut v = vec![];
|
||||
out.shapes.iter().for_each(|c| texts(&c.shape, &mut v));
|
||||
v
|
||||
}
|
||||
|
||||
/// The rect of the widget keyed `key` (last frame). egui 0.37's `WidgetRect.parent_id` is
|
||||
/// the parent Ui's unstable `unique_id`, not the stable `scope_id` widgets are salted with;
|
||||
/// debug builds remember the `with(key)` that made each id, so match that.
|
||||
fn widget(&self, key: impl std::hash::Hash + std::fmt::Debug + Copy) -> Rect {
|
||||
let suffix = format!(".with({key:?})");
|
||||
self.ctx
|
||||
.viewport(|vp| {
|
||||
vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).find(|w| format!("{:?}", w.id).ends_with(&suffix)).map(|w| w.rect)
|
||||
})
|
||||
.expect("widget drawn")
|
||||
/// Match actual IDs in the panel or its immediate horizontal rows. `parent_id` is an
|
||||
/// unstable auto ID, and the human-readable `Id` debug source is absent in release builds.
|
||||
fn widget(&self, key: impl egui::AsIdSalt + Copy) -> Rect {
|
||||
// Match Ui::new_child: its default child salt is an IdSalt, not the raw string.
|
||||
let row_scope = self.scope.with(egui::IdSalt::new("child"));
|
||||
let ids = [self.scope.with(key), row_scope.with(key)];
|
||||
// Outside run_ui, read_response can prefer the older this_pass geometry after
|
||||
// end_pass swaps the passes. Use the completed frame, including after Hide Options.
|
||||
let matches: Vec<_> = self.ctx.viewport(|vp| ids.into_iter().filter_map(|id| vp.prev_pass.widgets.get(id).map(|w| w.rect)).collect());
|
||||
assert_eq!(matches.len(), 1, "expected one widget for {key:?}");
|
||||
matches[0]
|
||||
}
|
||||
|
||||
fn click(&mut self, app: &mut VectorcraftApp, at: Pos2) -> Vec<(String, Pos2)> {
|
||||
@@ -252,7 +257,7 @@ fn the_proxy_toggles_fill_and_stroke_and_stays_with_options_hidden() {
|
||||
let fill = p.widget("fill-proxy");
|
||||
p.click(&mut app, fill.left_top() + vec2(2.0, 2.0));
|
||||
assert!(app.session.fill_active, "the proxy works with the options hidden");
|
||||
p.widget(("grad-stop", 1));
|
||||
p.widget(("grad-stop", 1_usize));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -331,7 +336,7 @@ fn a_selected_diamond_shows_and_sets_its_midpoint_in_location() {
|
||||
let mut p = Panel::new();
|
||||
let texts = p.frame(&mut app, vec![], Modifiers::NONE);
|
||||
assert!(texts.iter().any(|(t, _)| t == "100%"), "the stop's location: {texts:?}");
|
||||
let diamond = p.widget(("grad-mid", 0));
|
||||
let diamond = p.widget(("grad-mid", 0_usize));
|
||||
let texts = p.click(&mut app, diamond.center());
|
||||
assert_eq!(app.session.selected_stop(), None, "the diamond replaces the stop selection");
|
||||
assert!(texts.iter().any(|(t, _)| t == "30%"), "the midpoint: {texts:?}");
|
||||
@@ -343,7 +348,7 @@ fn a_selected_diamond_shows_and_sets_its_midpoint_in_location() {
|
||||
p.frame(&mut app, vec![key(egui::Key::Enter, Modifiers::NONE)], Modifiers::NONE);
|
||||
assert!((fill(&app).gradient.stops[0].midpoint - 0.6).abs() < 1e-6, "{:?}", fill(&app).gradient.stops);
|
||||
// Clicking a stop selects it again.
|
||||
let stop = p.widget(("grad-stop", 0));
|
||||
let stop = p.widget(("grad-stop", 0_usize));
|
||||
let texts = p.click(&mut app, stop.center());
|
||||
assert_eq!(app.session.selected_stop(), Some(0));
|
||||
assert!(texts.iter().any(|(t, _)| t == "0%"));
|
||||
@@ -474,7 +479,7 @@ fn alt_drag_copies_a_stop_and_alt_dropping_on_a_stop_swaps_colours() {
|
||||
let mut app = app(json!({"gradient": {}}));
|
||||
let mut p = Panel::new();
|
||||
p.frame(&mut app, vec![], Modifiers::NONE);
|
||||
let (a, b) = (p.widget(("grad-stop", 0)).center(), p.widget(("grad-stop", 1)).center());
|
||||
let (a, b) = (p.widget(("grad-stop", 0_usize)).center(), p.widget(("grad-stop", 1_usize)).center());
|
||||
let mid = pos2((a.x + b.x) / 2.0, a.y);
|
||||
let offsets = |app: &VectorcraftApp| fill(app).gradient.stops.iter().map(|s| (s.offset * 10.0).round() / 10.0).collect::<Vec<_>>();
|
||||
// A plain drag moves the stop.
|
||||
@@ -499,7 +504,7 @@ fn a_dropped_swatch_adds_or_recolours_a_stop() {
|
||||
let mut app = app(json!({"gradient": {}}));
|
||||
let mut p = Panel::new();
|
||||
p.frame(&mut app, vec![], Modifiers::NONE);
|
||||
let (a, b) = (p.widget(("grad-stop", 0)).center(), p.widget(("grad-stop", 1)).center());
|
||||
let (a, b) = (p.widget(("grad-stop", 0_usize)).center(), p.widget(("grad-stop", 1_usize)).center());
|
||||
let drop = |p: &mut Panel, app: &mut VectorcraftApp, at: Pos2| {
|
||||
egui::DragAndDrop::set_payload(&p.ctx, PanelDrag::paint(Paint::solid(Color::rgb(1.0, 0.0, 0.0))));
|
||||
p.frame(app, vec![Event::PointerMoved(at)], Modifiers::NONE);
|
||||
@@ -509,7 +514,7 @@ fn a_dropped_swatch_adds_or_recolours_a_stop() {
|
||||
drop(&mut p, &mut app, pos2((a.x + b.x) / 2.0, a.y - 20.0));
|
||||
assert_eq!(hexes(&app), ["#ffffff", "#ff0000", "#000000"]);
|
||||
p.frame(&mut app, vec![], Modifiers::NONE);
|
||||
let last = p.widget(("grad-stop", 2)).center();
|
||||
let last = p.widget(("grad-stop", 2_usize)).center();
|
||||
drop(&mut p, &mut app, last);
|
||||
assert_eq!(hexes(&app), ["#ffffff", "#ff0000", "#ff0000"]);
|
||||
// A spot swatch tile dropped on a stop links it: editing the swatch recolours the stop.
|
||||
@@ -538,7 +543,7 @@ fn a_color_dropped_on_the_ramp_keeps_its_model_in_a_cmyk_document() {
|
||||
app.run("paint.setFill", json!({"gradient": {"stops": rgb}, "keepModel": true})).unwrap();
|
||||
let mut p = Panel::new();
|
||||
p.frame(&mut app, vec![], Modifiers::NONE);
|
||||
let (a, b) = (p.widget(("grad-stop", 0)).center(), p.widget(("grad-stop", 1)).center());
|
||||
let (a, b) = (p.widget(("grad-stop", 0_usize)).center(), p.widget(("grad-stop", 1_usize)).center());
|
||||
let at = pos2((a.x + b.x) / 2.0, a.y - 20.0);
|
||||
let red = Color::rgb(1.0, 0.0, 0.0);
|
||||
egui::DragAndDrop::set_payload(&p.ctx, PanelDrag::paint(Paint::solid(red)));
|
||||
@@ -554,7 +559,7 @@ fn double_clicking_a_stop_opens_the_shared_popover_beside_it() {
|
||||
let mut app = app(json!({"gradient": {}}));
|
||||
let mut p = Panel::new();
|
||||
p.frame(&mut app, vec![], Modifiers::NONE);
|
||||
let stop = p.widget(("grad-stop", 1));
|
||||
let stop = p.widget(("grad-stop", 1_usize));
|
||||
let b = |pressed| Event::PointerButton { pos: stop.center(), button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
|
||||
p.frame(&mut app, vec![Event::PointerMoved(stop.center()), b(true), b(false), b(true), b(false)], Modifiers::NONE);
|
||||
let d = app.ui.dialog.clone().expect("the popover opened");
|
||||
@@ -755,7 +760,7 @@ fn a_stop_dragged_off_the_ramp_or_deleted_with_the_button_beside_it_goes() {
|
||||
let mut app = app(json!({"gradient": {"stops": stops}}));
|
||||
let mut p = Panel::new();
|
||||
p.frame(&mut app, vec![], Modifiers::NONE);
|
||||
let mid = p.widget(("grad-stop", 1)).center();
|
||||
let mid = p.widget(("grad-stop", 1_usize)).center();
|
||||
// Dragged down off the ramp, the middle stop goes.
|
||||
p.drag(&mut app, mid, mid + vec2(0.0, 60.0), Modifiers::NONE);
|
||||
assert_eq!(hexes(&app), ["#ffffff", "#000000"]);
|
||||
@@ -773,7 +778,7 @@ fn a_stop_dragged_off_the_ramp_or_deleted_with_the_button_beside_it_goes() {
|
||||
assert_eq!(hexes(&app), ["#ffffff", "#ff0000", "#000000"]);
|
||||
app.run("edit.redo", json!({})).unwrap();
|
||||
// Two stops stay two: dragged off, the stop comes back, and the button is off.
|
||||
let first = p.widget(("grad-stop", 0)).center();
|
||||
let first = p.widget(("grad-stop", 0_usize)).center();
|
||||
p.drag(&mut app, first, first + vec2(0.0, 60.0), Modifiers::NONE);
|
||||
assert_eq!(hexes(&app), ["#ffffff", "#000000"]);
|
||||
app.run("gradient.selectStop", json!({"index": 0})).unwrap();
|
||||
|
||||
@@ -176,6 +176,9 @@ struct Out {
|
||||
column: Option<Option<ColumnDrag>>,
|
||||
/// Rows to open or close, and whether to open them.
|
||||
toggle: Vec<(NodeId, bool)>,
|
||||
/// The highlighted rows after this frame's row click, when one happened: the click's action
|
||||
/// runs after `delete_key`, so `view.rows` is still the state before it.
|
||||
rows_after_click: Option<Vec<NodeId>>,
|
||||
}
|
||||
|
||||
/// The panel state that says the Layers panel's rows have the keyboard ([`super::has_row_keys`]).
|
||||
@@ -361,12 +364,17 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
// Keyboard: Delete removes the highlighted rows while the panel has the keyboard (a row was
|
||||
// clicked last, not the canvas).
|
||||
// The panel keeps it while it is drawn ([`super::has_row_keys`]); the app's shortcuts leave
|
||||
// Delete and Backspace to it meanwhile ([`super::rows_take_delete`]).
|
||||
// Delete and Backspace to it meanwhile ([`super::rows_take_delete`]). With no highlighted rows
|
||||
// left (the last one was deleted) the panel gives the keys back, so Delete clears the selected
|
||||
// art again instead of doing nothing.
|
||||
let pressed_outside = ui.input(|i| i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| !list_rect.contains(p)));
|
||||
let focus = super::has_row_keys(ui.ctx(), ROW_KEYS) && !pressed_outside;
|
||||
// A click's highlight action runs after this block, so read the rows it will leave, not the
|
||||
// snapshot `view.rows` took before it.
|
||||
let rows = out.rows_after_click.as_deref().unwrap_or(&view.rows);
|
||||
let focus = super::has_row_keys(ui.ctx(), ROW_KEYS) && !pressed_outside && !rows.is_empty();
|
||||
super::set_row_keys(ui.ctx(), ROW_KEYS, focus);
|
||||
if focus
|
||||
&& !view.rows.is_empty()
|
||||
&& !rows.is_empty()
|
||||
&& ui.ctx().memory(|m| m.focused().is_none())
|
||||
&& ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Delete) || i.consume_key(egui::Modifiers::NONE, egui::Key::Backspace))
|
||||
{
|
||||
@@ -376,6 +384,11 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
for (c, p) in out.actions {
|
||||
app.run(&c, p).ok();
|
||||
}
|
||||
// A deletion that took the last highlighted row leaves the panel nothing to delete. Release the
|
||||
// keys now, not at the next draw, so the very next frame's Delete reaches Edit › Clear.
|
||||
if app.session.active().is_some_and(|st| st.highlighted_rows().is_empty()) {
|
||||
super::set_row_keys(ui.ctx(), ROW_KEYS, false);
|
||||
}
|
||||
see_selection(app, ui, selected);
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +91,9 @@ pub(super) fn resolve_click(ui: &Ui, view: &View, list: &[Row], out: &mut Out) {
|
||||
if click.moves_anchor() {
|
||||
ui.data_mut(|d| d.insert_temp(key("anchor"), id));
|
||||
}
|
||||
let mut rows = view.rows.clone();
|
||||
widgets::apply_row_click(&mut rows, &click);
|
||||
out.rows_after_click = Some(rows);
|
||||
let ids = |rows: &[NodeId]| rows.iter().map(|r| r.0).collect::<Vec<_>>();
|
||||
out.actions.push(match click {
|
||||
widgets::RowClick::Only(id) => ("layer.setCurrent".into(), json!({"id": id.0})),
|
||||
|
||||
@@ -1348,10 +1348,15 @@ fn app_click(ctx: &egui::Context, app: &mut VectorcraftApp, pos: egui::Pos2, f:
|
||||
}
|
||||
}
|
||||
|
||||
/// `key` pressed and released in an app-like frame of `f`, then one more frame.
|
||||
fn app_key(ctx: &egui::Context, app: &mut VectorcraftApp, key: egui::Key, f: fn(&mut VectorcraftApp, &mut egui::Ui)) {
|
||||
/// `key` pressed and released in one app-like frame of `f`, with no idle frame after it.
|
||||
fn app_key_now(ctx: &egui::Context, app: &mut VectorcraftApp, key: egui::Key, f: fn(&mut VectorcraftApp, &mut egui::Ui)) {
|
||||
let ev = |pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::NONE };
|
||||
app_frame(ctx, app, vec![ev(true), ev(false)], f);
|
||||
}
|
||||
|
||||
/// `key` pressed and released in an app-like frame of `f`, then one more frame.
|
||||
fn app_key(ctx: &egui::Context, app: &mut VectorcraftApp, key: egui::Key, f: fn(&mut VectorcraftApp, &mut egui::Ui)) {
|
||||
app_key_now(ctx, app, key, f);
|
||||
app_frame(ctx, app, vec![], f);
|
||||
}
|
||||
|
||||
@@ -1481,6 +1486,53 @@ fn after_a_row_click_delete_reaches_the_layers_panel_not_edit_clear() {
|
||||
assert!(d.node(rect).is_some(), "the rectangle on Layer 1 stays");
|
||||
}
|
||||
|
||||
/// After Delete removed the last highlighted Layers row, the panel has nothing left to delete: the
|
||||
/// next Delete must go back to the app and clear the selected object, not be swallowed by the
|
||||
/// panel's row keys.
|
||||
#[test]
|
||||
fn delete_after_the_last_highlighted_layer_row_is_deleted_clears_the_selection() {
|
||||
let ctx = drop_ctx();
|
||||
let mut app = app_with_rect();
|
||||
run(&mut app, "layer.new", json!({}));
|
||||
let rect = first_selected(&app).unwrap().id;
|
||||
let texts = frame_events(&ctx, &mut app, vec![], boxed_layers);
|
||||
let row = text_rect(&texts, "Layer 2").center();
|
||||
app_click(&ctx, &mut app, row, boxed_layers);
|
||||
app_key_now(&ctx, &mut app, egui::Key::Delete, boxed_layers);
|
||||
assert_eq!(app.session.active().unwrap().doc.layers.len(), 1, "Layer 2 is deleted");
|
||||
assert!(app.session.active().unwrap().selection.contains(rect), "the rectangle stays selected");
|
||||
app_key_now(&ctx, &mut app, egui::Key::Delete, boxed_layers);
|
||||
assert!(app.session.active().unwrap().doc.node(rect).is_none(), "Delete clears the selected object once the panel has no rows");
|
||||
}
|
||||
|
||||
/// Clicking a row right after the last highlighted row was deleted restores the row keys: Delete
|
||||
/// then deletes that row, not the selected art.
|
||||
#[test]
|
||||
fn clicking_a_row_after_the_last_one_was_deleted_keeps_delete_with_the_layers_panel() {
|
||||
let ctx = drop_ctx();
|
||||
let mut app = app_with_rect();
|
||||
run(&mut app, "layer.new", json!({}));
|
||||
run(&mut app, "layer.new", json!({}));
|
||||
let rect = first_selected(&app).unwrap().id;
|
||||
let texts = frame_events(&ctx, &mut app, vec![], boxed_layers);
|
||||
let layer2 = text_rect(&texts, "Layer 2").center();
|
||||
app_click(&ctx, &mut app, layer2, boxed_layers);
|
||||
app_key_now(&ctx, &mut app, egui::Key::Delete, boxed_layers);
|
||||
assert_eq!(app.session.active().unwrap().doc.layers.len(), 2, "Layer 2 is deleted");
|
||||
// Click Layer 3 and press Delete in the very next frame: the click's highlight action runs at
|
||||
// the end of its frame, so the panel must read the rows the click will leave.
|
||||
let texts = frame_events(&ctx, &mut app, vec![], boxed_layers);
|
||||
let layer3 = text_rect(&texts, "Layer 3").center();
|
||||
let button =
|
||||
|pressed| egui::Event::PointerButton { pos: layer3, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
|
||||
for e in [egui::Event::PointerMoved(layer3), button(true), button(false)] {
|
||||
app_frame(&ctx, &mut app, vec![e], boxed_layers);
|
||||
}
|
||||
app_key_now(&ctx, &mut app, egui::Key::Delete, boxed_layers);
|
||||
assert_eq!(app.session.active().unwrap().doc.layers.len(), 1, "Delete removes the clicked row");
|
||||
assert!(app.session.active().unwrap().doc.node(rect).is_some(), "the rectangle on Layer 1 stays");
|
||||
}
|
||||
|
||||
/// A tool that claims Delete and Backspace (the Artboard tool's active artboard) leaves them to a
|
||||
/// clicked Layers row.
|
||||
#[test]
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
//! Character Styles and Paragraph Styles panels (one implementation, two kinds).
|
||||
//!
|
||||
//! Click a style to apply it to the selected text (or the Type tool's selected characters);
|
||||
//! Alt-click applies it and clears overrides. The style of the selection is highlighted, with a
|
||||
//! "+" when the text overrides some of its attributes. New makes a style from the selected text;
|
||||
//! Alt-click on New first asks for its name (`ui.newCharStyle`, `ui.newParaStyle`).
|
||||
//! Alt-click applies it and clears overrides. Double-clicking a character style's row opens its
|
||||
//! Character Style Options (`ui.charStyleOptions`), as the panel menu does; a paragraph style's row
|
||||
//! opens its Paragraph Style Options, the rename dialog the panel menu opens. The style of the
|
||||
//! selection is highlighted, with a "+" when the text overrides some of its attributes. New makes
|
||||
//! a style from the selected text; Alt-click on New first asks for its name (`ui.newCharStyle`,
|
||||
//! `ui.newParaStyle`).
|
||||
|
||||
use std::cell::RefCell;
|
||||
|
||||
@@ -225,6 +228,19 @@ fn row_look(is_cur: bool, in_highlight: bool, hovered: bool, t: &Tokens) -> (Opt
|
||||
(fill, is_cur && in_highlight)
|
||||
}
|
||||
|
||||
/// Opens `name`'s options: Character Style Options for a character style, or the Paragraph Style
|
||||
/// Options the Paragraph Styles panel menu opens, which rename the style (`ui.paramDialog` runs
|
||||
/// `paraStyle.rename`).
|
||||
fn open_style_options(app: &mut VectorcraftApp, kind: Kind, name: &str) {
|
||||
if kind == Kind::Char {
|
||||
app.run("ui.charStyleOptions", json!({ "name": name })).ok();
|
||||
} else {
|
||||
let label = format!("{} Style Options", kind.what());
|
||||
app.run("ui.paramDialog", json!({ "command": format!("{}.rename", kind.prefix()), "label": label, "params": { "name": name, "to": name } }))
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
/// The style list and its New and Delete buttons. Shift-click highlights every style from the
|
||||
/// last one clicked without Shift through this one, and Cmd/Ctrl-click adds or removes one; both
|
||||
/// leave the text as it is. Delete deletes every highlighted style but the Normal style.
|
||||
@@ -235,6 +251,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) {
|
||||
let normal = all.first().map(|s| s.0.clone()).unwrap_or_default();
|
||||
let cur_name = cur.as_ref().map(|(n, _)| n.clone().unwrap_or_else(|| normal.clone()));
|
||||
let mut clicked: Option<(String, egui::Modifiers)> = None;
|
||||
// A double-clicked Character Style's row, whose options open after the list is laid out.
|
||||
let mut options: Option<String> = None;
|
||||
// Set on a double-click's second click, which must not apply the style again.
|
||||
let mut double_click = false;
|
||||
let listed: Vec<&str> = all.iter().map(|s| s.0.as_str()).collect();
|
||||
// A highlighted style that is no longer listed (renamed in Style Options, deleted by a command
|
||||
// or undone) is no longer highlighted.
|
||||
@@ -279,6 +299,17 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) {
|
||||
if resp.clicked() {
|
||||
clicked = Some((name.clone(), ui.input(|i| i.modifiers)));
|
||||
}
|
||||
// A double-click on a user character style opens its Character Style Options, as the
|
||||
// panel menu's item does; with Shift, Cmd/Ctrl or Alt held it only follows the click
|
||||
// rules. The second click never applies the style again, also on the Normal style,
|
||||
// which has no options to open.
|
||||
if resp.double_clicked() {
|
||||
double_click = true;
|
||||
let m = ui.input(|i| i.modifiers);
|
||||
if *name != normal && !(m.shift || m.command || m.alt) {
|
||||
options = Some(name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if scroll_to.is_some() {
|
||||
@@ -287,12 +318,16 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) {
|
||||
let has_text = cur.is_some();
|
||||
if let Some((name, m)) = clicked {
|
||||
sel = super::swatches::click_selection(ui, kind.anchor_key(), sel, &listed, &name, m);
|
||||
// Shift and Cmd/Ctrl clicks only highlight, as in the Graphic Styles panel.
|
||||
if has_text && !(m.shift || m.command) {
|
||||
// Shift and Cmd/Ctrl clicks only highlight, as in the Graphic Styles panel. The second
|
||||
// click of a double-click opens the options instead of applying the style again.
|
||||
if has_text && !(m.shift || m.command) && options.is_none() && !double_click {
|
||||
let p = apply_target(app, kind, json!({ "name": name, "clearOverrides": m.alt }));
|
||||
app.run(&format!("{}.apply", kind.prefix()), p).ok();
|
||||
}
|
||||
}
|
||||
if let Some(name) = options {
|
||||
open_style_options(app, kind, &name);
|
||||
}
|
||||
set_highlighted(ui.ctx(), kind, sel.clone());
|
||||
let doomed: Vec<&String> = sel.iter().filter(|s| **s != normal).collect();
|
||||
let deletable = !doomed.is_empty();
|
||||
@@ -389,12 +424,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) {
|
||||
if menu_item(ui, &crate::i18n::fmt(tl!("{kind} Style Options…"), &[("kind", kind.what_label())]), user, false)
|
||||
&& let Some(n) = &sel
|
||||
{
|
||||
if kind == Kind::Char {
|
||||
app.run("ui.charStyleOptions", json!({ "name": n })).ok();
|
||||
} else {
|
||||
let label = format!("{} Style Options", kind.what());
|
||||
app.run("ui.paramDialog", json!({ "command": format!("{p}.rename"), "label": label, "params": { "name": n, "to": n } })).ok();
|
||||
}
|
||||
open_style_options(app, kind, n);
|
||||
}
|
||||
ui.separator();
|
||||
// As commands, so a file dialog shown off the UI thread loads the styles when it answers.
|
||||
|
||||
@@ -261,6 +261,66 @@ fn click_label(ctx: &egui::Context, app: &mut VectorcraftApp, label: &str, mut d
|
||||
app.run_count.wrapping_sub(before)
|
||||
}
|
||||
|
||||
/// Double-clicking a user character style's row opens its Character Style Options once: the first
|
||||
/// click applies the style to the selected text, the second opens the dialog without applying it
|
||||
/// again. A paragraph style's row opens none, and the built-in Normal style opens none either.
|
||||
#[test]
|
||||
fn double_clicking_a_character_style_row_opens_its_options() {
|
||||
for kind in [Kind::Char, Kind::Para] {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 150})).unwrap();
|
||||
let id = app.run("text.create", json!({"x": 20, "y": 40, "text": "Hi"})).unwrap()["id"].clone();
|
||||
app.run("select.set", json!({"ids": [id]})).unwrap();
|
||||
app.run(&format!("{}.new", kind.prefix()), json!({"name": "Body"})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let mut draw = |app: &mut VectorcraftApp, ui: &mut egui::Ui| show(app, ui, kind);
|
||||
frame_events(&ctx, &mut app, vec![], &mut draw);
|
||||
let texts = frame_events(&ctx, &mut app, vec![], &mut draw);
|
||||
let pos = text_rect(&texts, "Body").center();
|
||||
let before = app.run_count;
|
||||
click(&ctx, &mut app, pos, &mut draw);
|
||||
click(&ctx, &mut app, pos, &mut draw);
|
||||
let dialog = app.ui.dialog.as_ref();
|
||||
if kind == Kind::Char {
|
||||
let d = dialog.expect("double-clicking a Character Style's row opens its options");
|
||||
assert_eq!(d.kind, "charStyleOptions");
|
||||
assert_eq!(d.str("__style"), "Body");
|
||||
// One apply from the first click and the dialog from the second: no second apply.
|
||||
assert_eq!(app.run_count.wrapping_sub(before), 2, "one apply and the dialog");
|
||||
} else {
|
||||
let d = dialog.expect("double-clicking a Paragraph Style's row opens its options");
|
||||
assert_eq!(d.kind, "command");
|
||||
assert_eq!(d.str("__command"), "paraStyle.rename");
|
||||
assert_eq!(d.str("__label"), "Paragraph Style Options");
|
||||
// One apply from the first click and the rename dialog from the second.
|
||||
assert_eq!(app.run_count.wrapping_sub(before), 2, "one apply and the dialog");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A double-click on the built-in Normal style opens no options, and its second click applies it
|
||||
/// only once, as a user style's does.
|
||||
#[test]
|
||||
fn double_clicking_the_normal_style_opens_no_options() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 150})).unwrap();
|
||||
let id = app.run("text.create", json!({"x": 20, "y": 40, "text": "Hi"})).unwrap()["id"].clone();
|
||||
app.run("select.set", json!({"ids": [id]})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let mut draw = |app: &mut VectorcraftApp, ui: &mut egui::Ui| show(app, ui, Kind::Char);
|
||||
frame_events(&ctx, &mut app, vec![], &mut draw);
|
||||
let texts = frame_events(&ctx, &mut app, vec![], &mut draw);
|
||||
let normal = texts.iter().find(|(text, _)| text.contains("Normal")).map(|(_, r)| r.center());
|
||||
let pos = normal.expect("the Normal style's row is drawn");
|
||||
let before = app.run_count;
|
||||
click(&ctx, &mut app, pos, &mut draw);
|
||||
click(&ctx, &mut app, pos, &mut draw);
|
||||
assert!(app.ui.dialog.is_none(), "the Normal style has no Character Style Options");
|
||||
assert_eq!(app.run_count.wrapping_sub(before), 1, "the second click applies nothing");
|
||||
}
|
||||
|
||||
/// The panel menu's Duplicate highlights the copy and its Delete clears the highlight, as the
|
||||
/// panel's New and Delete buttons do; after a menu delete, Duplicate runs nothing.
|
||||
#[test]
|
||||
|
||||
@@ -511,10 +511,13 @@ fn category_fields(ui: &mut egui::Ui, app: &mut VectorcraftApp, d: &mut Dialog,
|
||||
}
|
||||
PrefKind::Bool => bool_row(ui, d, sp.key, sp.label),
|
||||
PrefKind::Num { min, max, unit } => {
|
||||
labeled(ui, sp.label, |ui| {
|
||||
labeled(ui, sp.label, |ui, label_id| {
|
||||
let mut x = v.as_f64().unwrap_or(min);
|
||||
let new = if sp.key == "uiScaling" {
|
||||
widgets::slider(ui, egui::Slider::new(&mut x, min..=max).step_by(0.05).text(tl!("Smaller ↔ Larger"))).changed().then_some(x)
|
||||
// Without internal text, both slider controls inherit the row's name across focus changes.
|
||||
let response = widgets::slider(ui, egui::Slider::new(&mut x, min..=max).step_by(0.05)).labelled_by(label_id);
|
||||
ui.add(egui::Label::new(tl!("Smaller ↔ Larger")).wrap_mode(egui::TextWrapMode::Extend));
|
||||
response.changed().then_some(x)
|
||||
} else {
|
||||
widgets::range_field(ui, sp.key, x, min..=max, &format!(" {unit}"), 3, 110.0)
|
||||
};
|
||||
@@ -527,17 +530,19 @@ fn category_fields(ui: &mut egui::Ui, app: &mut VectorcraftApp, d: &mut Dialog,
|
||||
// In the dialog's own Units choice, so a change there shows right away.
|
||||
let unit = vectorcraft_doc::Unit::named(&d.str(measure.pref_key())).unwrap_or_default();
|
||||
let x = d.f64(sp.key, min);
|
||||
labeled(ui, sp.label, |ui| {
|
||||
labeled(ui, sp.label, |ui, _| {
|
||||
if let Some(x) = widgets::num_field(ui, sp.key, Some(x), unit, 110.0) {
|
||||
d.fields.insert(sp.key.into(), json!(x.clamp(min, max)));
|
||||
}
|
||||
});
|
||||
}
|
||||
PrefKind::Int { min, max } => {
|
||||
labeled(ui, sp.label, |ui| {
|
||||
labeled(ui, sp.label, |ui, label_id| {
|
||||
let mut x = v.as_i64().unwrap_or(min);
|
||||
let new = if sp.key == "anchorSize" {
|
||||
widgets::slider(ui, egui::Slider::new(&mut x, min..=max).show_value(false).text(tl!("Size"))).changed().then_some(x)
|
||||
let response = widgets::slider(ui, egui::Slider::new(&mut x, min..=max).show_value(false)).labelled_by(label_id);
|
||||
ui.add(egui::Label::new(tl!("Size")).wrap_mode(egui::TextWrapMode::Extend));
|
||||
response.changed().then_some(x)
|
||||
} else {
|
||||
widgets::range_field(ui, sp.key, x as f64, min as f64..=max as f64, "", 0, 110.0).map(|x| x as i64)
|
||||
};
|
||||
@@ -569,11 +574,11 @@ fn category_fields(ui: &mut egui::Ui, app: &mut VectorcraftApp, d: &mut Dialog,
|
||||
}
|
||||
}
|
||||
PrefKind::Color => {
|
||||
labeled(ui, sp.label, |ui| {
|
||||
labeled(ui, sp.label, |ui, label_id| {
|
||||
let hex = v.as_str().unwrap_or("#000000");
|
||||
let c = vectorcraft_color::Color::from_hex(hex).map(|c| c.to_rgba8(1.0)).unwrap_or([0, 0, 0, 255]);
|
||||
let mut rgb = [c[0], c[1], c[2]];
|
||||
if ui.color_edit_button_srgb(&mut rgb).changed() {
|
||||
if ui.color_edit_button_srgb(&mut rgb).labelled_by(label_id).changed() {
|
||||
d.fields.insert(sp.key.into(), json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2])));
|
||||
}
|
||||
ui.label(egui::RichText::new(hex).color(t.text_dim).size(11.0));
|
||||
@@ -581,7 +586,7 @@ fn category_fields(ui: &mut egui::Ui, app: &mut VectorcraftApp, d: &mut Dialog,
|
||||
}
|
||||
PrefKind::Text if sp.key == "interfaceLanguage" => {
|
||||
// `auto` plus every registered language, by its own name.
|
||||
labeled(ui, sp.label, |ui| {
|
||||
labeled(ui, sp.label, |ui, _| {
|
||||
let cur = v.as_str().unwrap_or("auto");
|
||||
let codes: Vec<&str> = std::iter::once("auto").chain(crate::i18n::Lang::for_picker().into_iter().map(|l| l.code())).collect();
|
||||
let labels: Vec<&str> =
|
||||
@@ -598,7 +603,7 @@ fn category_fields(ui: &mut egui::Ui, app: &mut VectorcraftApp, d: &mut Dialog,
|
||||
// Plug-ins Folder: typed, or picked with Choose… where the host has a folder picker
|
||||
// (not on the web).
|
||||
PrefKind::Text if folder_check(sp.key).is_some() => {
|
||||
labeled(ui, sp.label, |ui| {
|
||||
labeled(ui, sp.label, |ui, _| {
|
||||
let mut s = v.as_str().unwrap_or("").to_string();
|
||||
if ui.add(egui::TextEdit::singleline(&mut s).align(egui::Align2::LEFT_CENTER).desired_width(200.0)).changed() {
|
||||
d.fields.insert(sp.key.into(), json!(s));
|
||||
@@ -609,7 +614,7 @@ fn category_fields(ui: &mut egui::Ui, app: &mut VectorcraftApp, d: &mut Dialog,
|
||||
});
|
||||
}
|
||||
PrefKind::Text => {
|
||||
labeled(ui, sp.label, |ui| {
|
||||
labeled(ui, sp.label, |ui, _| {
|
||||
let mut s = v.as_str().unwrap_or("").to_string();
|
||||
if ui.add(egui::TextEdit::singleline(&mut s).align(egui::Align2::LEFT_CENTER).desired_width(260.0)).changed() {
|
||||
d.fields.insert(sp.key.into(), json!(s));
|
||||
@@ -644,7 +649,7 @@ fn picas_in_use(d: &Dialog) -> bool {
|
||||
}
|
||||
|
||||
fn choice_row(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, opts: &[(&str, &str)], v: &Value) {
|
||||
labeled(ui, label, |ui| {
|
||||
labeled(ui, label, |ui, _| {
|
||||
let cur = v.as_str().unwrap_or("");
|
||||
let cur_label = opts.iter().find(|o| o.0 == cur).map(|o| o.1).unwrap_or(cur);
|
||||
let labels: Vec<&str> = opts.iter().map(|o| o.1).collect();
|
||||
@@ -664,14 +669,16 @@ fn bool_row(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
fn labeled(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui)) {
|
||||
fn labeled(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui, egui::Id)) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.horizontal(|ui| {
|
||||
ui.allocate_ui_with_layout(egui::vec2(210.0, 22.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
ui.set_min_width(210.0);
|
||||
ui.add(egui::Label::new(egui::RichText::new(format!("{}{}", tl!(label), tl!(":"))).color(t.text)).truncate());
|
||||
});
|
||||
add(ui);
|
||||
let label = ui
|
||||
.allocate_ui_with_layout(egui::vec2(210.0, 22.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
ui.set_min_width(210.0);
|
||||
ui.add(egui::Label::new(egui::RichText::new(format!("{}{}", tl!(label), tl!(":"))).color(t.text)).truncate())
|
||||
})
|
||||
.inner;
|
||||
ui.label_inputs_by(label.id, |ui| add(ui, label.id));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -955,6 +955,95 @@ fn ok_refuses_a_typed_folder_that_choose_would_refuse() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Preferences form rows associate each input with its visible label for accessibility tools.
|
||||
#[test]
|
||||
fn preference_inputs_expose_their_visible_labels() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut app = app();
|
||||
open(&mut app, Some("General"));
|
||||
let mut fonts = false;
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 700.0)).with_accessibility_check(false).build_ui_state(
|
||||
move |ui, app: &mut VectorcraftApp| {
|
||||
if !std::mem::replace(&mut fonts, true) {
|
||||
crate::theme::install_fonts(ui.ctx());
|
||||
return;
|
||||
}
|
||||
show(app, ui.ctx());
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(3);
|
||||
let _ = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Keyboard Increment:");
|
||||
}
|
||||
|
||||
/// Render the real category without the dialog's scroll viewport clipping later rows.
|
||||
fn preference_category_harness(category: &'static str) -> egui_kittest::Harness<'static, (VectorcraftApp, Dialog)> {
|
||||
let mut app = app();
|
||||
app.services.pick_folder = Some(Box::new(|| None));
|
||||
open(&mut app, Some(category));
|
||||
let dialog = app.ui.dialog.take().unwrap();
|
||||
let mut fonts = false;
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 1400.0)).with_accessibility_check(false).build_ui_state(
|
||||
move |ui, (app, dialog): &mut (VectorcraftApp, Dialog)| {
|
||||
if !std::mem::replace(&mut fonts, true) {
|
||||
crate::theme::install_fonts(ui.ctx());
|
||||
return;
|
||||
}
|
||||
category_fields(ui, app, dialog, category);
|
||||
},
|
||||
(app, dialog),
|
||||
);
|
||||
h.run_steps(3);
|
||||
h
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preference_colour_well_exposes_its_row_label_and_value() {
|
||||
use egui_kittest::kittest::{NodeT, Queryable};
|
||||
let h = preference_category_harness("Guides & Grid");
|
||||
let expected = format!("{}ff", h.state().1.str("patternTileEdgeColor").to_lowercase());
|
||||
let colour = h.get_by_role_and_label(egui::accesskit::Role::ColorWell, "Tile Edge Color:");
|
||||
assert_eq!(colour.accesskit_node().value().as_deref(), Some(expected.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preference_scale_labels_both_controls_across_focus_changes() {
|
||||
use egui::accesskit::Role;
|
||||
use egui_kittest::kittest::{NodeT, Queryable};
|
||||
let mut h = preference_category_harness("User Interface");
|
||||
let expected = h.state().1.f64("uiScaling", 1.0);
|
||||
for focus in [None, Some(Role::SpinButton), Some(Role::Slider)] {
|
||||
if let Some(role) = focus {
|
||||
h.get_by_role_and_label(role, "Scale:").focus();
|
||||
h.run_steps(3);
|
||||
assert!(h.get_by_role_and_label(role, "Scale:").accesskit_node().is_focused());
|
||||
}
|
||||
for role in [Role::Slider, Role::SpinButton] {
|
||||
let control = h.get_by_role_and_label(role, "Scale:");
|
||||
assert_eq!(control.accesskit_node().numeric_value(), Some(expected));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preference_anchor_slider_exposes_its_row_label_and_value() {
|
||||
use egui::accesskit::Role;
|
||||
use egui_kittest::kittest::{NodeT, Queryable};
|
||||
let h = preference_category_harness("Selection & Anchor Display");
|
||||
let slider = h.get_by_role_and_label(Role::Slider, "Size:");
|
||||
assert_eq!(slider.accesskit_node().numeric_value(), Some(h.state().1.f64("anchorSize", 3.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preference_folder_label_preserves_the_choose_action_name() {
|
||||
use egui::accesskit::Role;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let h = preference_category_harness("File Handling");
|
||||
let _ = h.get_by_role_and_label(Role::TextInput, "Folder:");
|
||||
let _ = h.get_by_role_and_label(Role::Button, "Choose…");
|
||||
assert!(h.query_by_role_and_label(Role::Button, "Folder:").is_none());
|
||||
}
|
||||
|
||||
/// Under automation roots, OK refuses a typed folder outside them before it looks at the folder.
|
||||
#[test]
|
||||
fn ok_refuses_a_typed_folder_outside_the_automation_roots_before_looking_at_it() {
|
||||
|
||||
@@ -370,6 +370,9 @@ pub struct UiState {
|
||||
/// That popup's first text field is still to take the keyboard.
|
||||
#[serde(skip)]
|
||||
pub transient_focus: bool,
|
||||
/// Type › Find Font…'s last Find In choice (`find_font::FIND_IN`), kept while the app runs.
|
||||
#[serde(skip)]
|
||||
pub find_font_in: String,
|
||||
pub control_bar: bool,
|
||||
/// The Control bar is docked to the bottom of the window instead of the top
|
||||
/// (`window.control.dock`).
|
||||
@@ -974,6 +977,7 @@ impl Default for UiState {
|
||||
open_panel: None,
|
||||
transient_panel: None,
|
||||
transient_focus: false,
|
||||
find_font_in: String::new(),
|
||||
control_bar: false,
|
||||
control_bar_bottom: false,
|
||||
toolbar: true,
|
||||
|
||||
@@ -227,7 +227,7 @@ impl Window {
|
||||
}
|
||||
|
||||
/// One headless frame of the whole window, `seconds` after the last one, with `events`.
|
||||
fn after(&mut self, seconds: f64, events: Vec<egui::Event>) {
|
||||
fn after(&mut self, seconds: f64, events: Vec<egui::Event>) -> egui::FullOutput {
|
||||
self.time += seconds;
|
||||
let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0));
|
||||
let raw = egui::RawInput { screen_rect: Some(rect), time: Some(self.time), events, ..Default::default() };
|
||||
@@ -237,6 +237,7 @@ impl Window {
|
||||
app.ui(ui);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
out
|
||||
}
|
||||
|
||||
/// A press and release of Escape, `seconds` after the last frame.
|
||||
@@ -336,12 +337,18 @@ fn escape_after_ending_isolation_or_with_the_rotate_view_tool_resets_the_view()
|
||||
#[test]
|
||||
fn escape_after_closing_a_popup_or_during_a_ruler_origin_drag_keeps_the_rotation() {
|
||||
let mut w = Window::new();
|
||||
// The menu's button has the id `ComboBox::from_id_salt("rot-combo")` gives it in the status bar's
|
||||
// row, which hosts other widgets too. egui 0.37 salts the Ui's stable `scope_id`, not the
|
||||
// widget rect's unstable `parent_id`; in debug builds the id's debug source names its salt.
|
||||
let widgets: Vec<egui::WidgetRect> = w.ctx.viewport(|vp| vp.prev_pass.widgets.layers().flat_map(|(_, ws)| ws.iter().copied()).collect());
|
||||
let combo = widgets.iter().find(|r| format!("{:?}", r.id).contains("rot-combo")).copied();
|
||||
let combo = combo.expect("the status bar's angle menu");
|
||||
// Find the actual combo by its accessible name, which is present in both build profiles;
|
||||
// `Id`'s debug source (including "rot-combo") only exists with debug assertions enabled.
|
||||
w.ctx.enable_accesskit();
|
||||
let out = w.after(0.0, vec![]);
|
||||
let tree = out.platform_output.accesskit_update.expect("the window's accessibility tree");
|
||||
let combos: Vec<_> =
|
||||
tree.nodes.iter().filter(|(_, node)| node.role() == egui::accesskit::Role::ComboBox && node.label() == Some("Rotation")).collect();
|
||||
assert_eq!(combos.len(), 1, "the status bar's angle menu");
|
||||
let combo = w
|
||||
.ctx
|
||||
.viewport(|vp| vp.prev_pass.widgets.layers().flat_map(|(_, ws)| ws.iter()).find(|r| r.id.accesskit_id() == combos[0].0).copied())
|
||||
.expect("the angle menu's widget");
|
||||
w.click(0.0, egui::PointerButton::Primary, combo.rect.center());
|
||||
w.after(0.0, vec![]);
|
||||
assert!(craft_ui::ComboBox::is_open(&w.ctx, combo.id), "the angle menu is open");
|
||||
|
||||
@@ -126,7 +126,7 @@ the two can still win that race.
|
||||
| `engine.execute` `file.exportSelection` | `{multiple?}` | File › Export Selection…: collects the selected objects as one asset (`assets.add`; one per object with `multiple: true`) and opens `exportForScreens` on its Assets tab with just them checked → `{assets}`. `document.exportSelection` still exports the selection in one go, without the dialog |
|
||||
| CSS Properties panel | `window.panel {panel: "cssProperties"}`; `engine.execute` `css.copy` / `css.exportFile` | the panel shows `css.selection` of the selection (after Generate CSS, while nothing is selected, `css.generate`); its menu holds the options the commands take (`units`, `position`, `dimensions`, `unnamed`, `rasterize`). `css.copy {scope?, …options}` copies the CSS to the clipboard → `{css, rules}`; `css.exportFile {path?, scope?, …options}` writes `css.export` to `path` (else a picked file; the web downloads it) with the pictures of rasterized art next to it → `{path, rules, images, warnings}`, and the status bar shows the first of `warnings` as Export As shows an export's notes |
|
||||
|
||||
| `ui.dialog.*` on `importPdf` | `{field: "page" \| "allPages" \| "range" \| "cropTo" \| "password", value}` | opening (`app.open`, `file.open`) a PDF or `.ai` with several pages or a password, or placing one (`file.place` without `page`), opens the Import PDF dialog (`mode`: `open` or `place`; `pageCount`). While `__locked` is true, set `password` and `ui.dialog.confirm` to unlock it (a one-page file then opens or is placed). Then set `allPages: false` and `range` ("2-3, 5") to open some pages, or `page` to place one, and `cropTo` (`bounding`, `art`, `crop`, `trim`, `bleed`, `media`); `ui.dialog.confirm` opens or places them (placing keeps the other `file.place` params, such as `link`, `template` and `at`, in `__place`). `document.open {pages, cropTo, password}` and `file.place {page, crop, password}` do the same without the dialog |
|
||||
| `ui.dialog.*` on `importPdf` | `{field: "page" \| "allPages" \| "range" \| "cropTo" \| "password", value}` | opening (`app.open`, `file.open`) a PDF, `.ai` or `.ait` with a password, or one with several pages unless it is a `.ai` or `.ait` with its editing data or carries its VectorCraft document (those open whole), or placing (`file.place` without `page`) any of them with several pages or a password, opens the Import PDF dialog (`mode`: `open` or `place`; `pageCount`). While `__locked` is true, set `password` and `ui.dialog.confirm` to unlock it (a one-page file then opens or is placed). Then set `allPages: false` and `range` ("2-3, 5") to open some pages, or `page` to place one, and `cropTo` (`bounding`, `art`, `crop`, `trim`, `bleed`, `media`); `ui.dialog.confirm` opens or places them (placing keeps the other `file.place` params, such as `link`, `template` and `at`, in `__place`). `document.open {pages, cropTo, password}` and `file.place {page, crop, password}` do the same without the dialog |
|
||||
|
||||
Swatch editors: `engine.execute` with `ui.swatchOptions {name}` opens the `swatchOptions` dialog for a colour swatch
|
||||
(fields `name`, `spot`, `global`, `mode`: `gray`/`rgb`/`hsb`/`lab`/`cmyk`/`web`, `color`: `"#rrggbb"` or a colour object,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Effects parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Extrude & Bevel, Inflate and Rotate apply to groups and layers, their objects turning about one shared axis (#4506); the Transform effect's copies of a path with a fill and a stroke (or several fills and strokes, an even-odd fill, or a partly transparent one) are drawn each above the one before, as Expand Appearance makes them (#5920); Expand Appearance makes each copy of a path's Transform effect an object of its own (#5612); previously: Pixelate › Color Halftone: Max Radius in pixels of the Document Raster Effects resolution, 4–127 px, with older documents converted on opening and radii written back in points for older app versions (#3260); Effect dialogs clamp bounded length inputs immediately on commit and flash rejection on invalid input (#4638); previously: Drop Shadow Darkness uses fixed RGB subtraction and CMYK black addition; scores unchanged; Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add an overprinting strip of the lighter colour where two solid colours meet; recounted with Hard Mix (#3061) and Soft Mix already in the catalogue: Pathfinder 13/13, Illustrator effects 49/50) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** minor (Extrude & Bevel, Inflate and Rotate merge contours that cross, touch or overlap, as type often draws a glyph, before building a solid; previously: Extrude & Bevel, Inflate and Rotate apply to groups and layers, their objects turning about one shared axis (#4506); the Transform effect's copies of a path with a fill and a stroke (or several fills and strokes, an even-odd fill, or a partly transparent one) are drawn each above the one before, as Expand Appearance makes them (#5920); Expand Appearance makes each copy of a path's Transform effect an object of its own (#5612); previously: Pixelate › Color Halftone: Max Radius in pixels of the Document Raster Effects resolution, 4–127 px, with older documents converted on opening and radii written back in points for older app versions (#3260); Effect dialogs clamp bounded length inputs immediately on commit and flash rejection on invalid input (#4638); previously: Drop Shadow Darkness uses fixed RGB subtraction and CMYK black addition; scores unchanged; Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add an overprinting strip of the lighter colour where two solid colours meet; recounted with Hard Mix (#3061) and Soft Mix already in the catalogue: Pathfinder 13/13, Illustrator effects 49/50) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
Every entry of Illustrator's Effect menu, by its public feature name, against VectorCraft's effects catalogue
|
||||
(`effect_catalog()` in `crates/effects/src/lib.rs`) and the Effect menu built from it
|
||||
@@ -179,7 +179,7 @@ VectorCraft choices; an original approximation, never compared with another appl
|
||||
| Item | Ours |
|
||||
|---|---|
|
||||
| Revolve | initial: live, editable profile, rotation and light gizmos, default-on Cap with continuous curved rims, smooth vector shading and round seam joins, SVG/PDF output, Expand Appearance ([revolve.md](revolve.md), #846) |
|
||||
| Extrude & Bevel, Inflate, Rotate | initial: live, depth, twist, taper, solid or hollow caps, five original bevel shapes, Inflate volume, both sides, twist, taper and cap, Rotate of every fill and stroke, groups and layers turned about one shared axis (#4506), Position presets, rotation and light gizmos, SVG/PDF output, Expand Appearance ([three-d.md](three-d.md)) |
|
||||
| Extrude & Bevel, Inflate, Rotate | initial: live, depth, twist, taper, solid or hollow caps, five original bevel shapes, Inflate volume, both sides, twist, taper and cap, Rotate of every fill and stroke, groups and layers turned about one shared axis (#4506), contours that cross, touch or overlap merged first ([three-d.md](three-d.md)), Position presets, rotation and light gizmos, SVG/PDF output, Expand Appearance ([three-d.md](three-d.md)) |
|
||||
| Materials panel, lighting, ray-traced render | Window › 3D and Materials panel with the object types (Plane, Extrude, Revolve, Inflate) and the Object, Rotation and Lighting settings of the applied effect ([three-d.md](three-d.md#3d-and-materials-panel)); one to ten directional lights with per-light Color (opaque RGB, white by default), Intensity, Rotation, Height, Softness and neutral Ambient Light, Revolve and colored Inflate use smooth vector shading; Extrude & Bevel and Rotate retain planar shading; original Standard, Diffuse, Top Left and Right lighting rigs; no materials, shadows or ray tracing |
|
||||
| 3D (Classic): Extrude & Bevel, Revolve, Rotate | none |
|
||||
| Turntable (30.0 beta) | none |
|
||||
@@ -193,6 +193,7 @@ angular cut caps, exact visibility, 3D Classic, Turntable) is 25–45 h.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-08 | minor | Extrude & Bevel, Inflate and Rotate merge contours that cross, touch or overlap (a glyph's arm across its stem) before building a solid, so the solid follows the shape as it is filled; others are built as drawn ([three-d.md](three-d.md)); scores and estimates unchanged |
|
||||
| 2026-11-06 | minor | Extrude & Bevel, Inflate and Rotate apply to a group or layer: a solid for every path, compound path and type in it, all turned about one shared axis as the public documentation describes for groups ([three-d.md](three-d.md#groups), #4506); scores and estimates unchanged |
|
||||
| 2026-11-06 | minor | The Transform effect's copies of a path or compound path whose fills and strokes stack (a fill and a stroke, several of either, an even-odd fill, or a partly transparent or blended one) are drawn each as an object of its own above the one before, as Expand Appearance makes them, instead of one path of every copy whose fill lay under every copy's stroke (#5920). A single opaque stroke or nonzero fill still draws as one path. Counts unchanged. |
|
||||
| 2026-11-04 | minor | Expand Appearance of a path or compound path whose Transform effect makes copies gives a group of one object per copy, each with the path's fills and strokes, instead of one compound path of every copy; copies of copies nest (#5612). Counts unchanged. |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; EPS and `.ai` editing data: a transparency set inside a clipping group stays after its `Q`; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`,
|
||||
`OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public
|
||||
@@ -50,7 +50,8 @@ private editing data. VectorCraft:
|
||||
A multi-layer mask (`Mb` … `Md` … `MB`) comes in as a clipping group in each layer it covers, each
|
||||
clipped by a copy of the mask's path.
|
||||
An opacity mask's art of one shown object, other than a guide or a clipping path, opens as that object in the
|
||||
editing data of a `.ai`, EPS or PDF; other art opens in a group.
|
||||
editing data of a `.ai`, EPS or PDF; other art opens in a group. Art in the mask that sets no paint of its
|
||||
own paints with the paint before the mask's dictionary, as the writer leaves out paint that doesn't change.
|
||||
RGB in the PDF page's `ICCBased` spaces keeps the file's numbers in a `.ai` (the `uncalibrated_rgb`
|
||||
import option, on for editor documents, off for PDFs), so `.ai` files tagged with a non-sRGB RGB profile
|
||||
open with different, unconverted RGB colours than before.
|
||||
@@ -267,7 +268,7 @@ An object in a pattern's art or in a brush's art takes the file's art styles in
|
||||
| Illustrator template `.ait` | open, save | yes | PDF-compatible only, from Save as Template (`.vctemplate` stays its default) | New from Template opens a saved `.ait` untitled with its layers and art (`tests_pdfedit.rs`) |
|
||||
| Illustrator `.ai` | open, save | yes (PDF-compatible + editing-data structure with template layers and ruler guides, File Info) | PDF-compatible, or legacy version 7 (layers and groups) | see above; no real-file corpus |
|
||||
| PDF | open, place, save | yes (layers, masks, editable text, security, overprint, File Info from the XMP packet and the document information dictionary; Crop To › Trim takes the bleed box as the document's bleed) | yes (presets, PDF/X-1a/3/4, layers, marks and bleed, ICC, fast web view, 1.3+; objects' URLs as link annotations, not in PDF/X) | fuzzed import; shared guarded writer and windowed reader ([pdf-backend.md](pdf-backend.md)), metadata, incomplete-content and dependency-source regressions; PDF text opens where the file draws it, glyph by glyph (#722: kerning or none, spacing per character, text rise as baseline shift, vertical writing, justified text boxes); alpha soft masks drawn as images import with the mask (#1551); a clip only counts as covering the page when its shape does (#1542); a PDF another app copied pastes as editable vector art (#1499); letter-spaced type on a path keeps its length and its glyph-by-glyph stroke comes in as one text object (#1586, #1585); a crafted name tree with shared nodes no longer takes exponential time on open (#1781); fonts named with hyphens (`Noto-Sans-Bold`) or with a composite font's CMap (`Name-Identity-H`) find the installed family; invisible text (rendering mode 3: a scanned page's recognized text) opens as type with no fill or stroke and saves back as invisible, searchable text (#2675); PDF/X-1a and X-3 flattening in a CMYK document keeps CMYK inks where it rasterizes (#2160) |
|
||||
| EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CID-keyed CJK type not read; #505; images encoded with `CCITTFaxDecode` (ITU-T T.4 Group 3 and T.6 Group 4, with `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1`) draw, and damaged data keeps the rows decoded before its first damaged row; Type 42 (TrueType) fonts set type at its size (#1553); a clip only counts as covering the page when its shape does (#1542); an EPS another app copied pastes as editable vector art (`clipboard.importEps`, #1499); a stitched shading keeps its hard colour changes on import (#1654); CMYK images, CMYK JPEGs included, keep their ink amounts on import, as from PDF, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask; ruzstd 0.9.1 for compressed editing data (#1807, RUSTSEC-2026-0344) |
|
||||
| EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CID-keyed CJK type not read; #505; images encoded with `CCITTFaxDecode` (ITU-T T.4 Group 3 and T.6 Group 4, with `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1`) draw, and damaged data keeps the rows decoded before its first damaged row; Type 42 (TrueType) fonts set type at its size (#1553); composite (Type 0) fonts with the 8/8 mapping, as PDF-to-PostScript converters write two-byte fonts, draw a Type 42 descendant's glyphs from the TrueType outlines it carries and set other descendants' characters as type at the composite font's size (other mappings and CID-keyed fonts are still set as type in the font of their name); a clip only counts as covering the page when its shape does (#1542); an EPS another app copied pastes as editable vector art (`clipboard.importEps`, #1499); a stitched shading keeps its hard colour changes on import (#1654); CMYK images, CMYK JPEGs included, keep their ink amounts on import, as from PDF, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask; ruzstd 0.9.1 for compressed editing data (#1807, RUSTSEC-2026-0344) |
|
||||
| PostScript `.ps` | open, place | yes (the EPS reader reads its first page; `.epsf` files open and place as EPS) | via Print to PostScript | `tests_epsimport.rs` |
|
||||
| SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests`; text on a path follows basic shapes as their SVG 2 equivalent paths (#1527); CSS transforms and the `font` shorthand (#1565), percentage lengths in symbols (#1596), Inkscape flowed text as area type (#1568) and Inkscape pages as artboards (#1601) import; an unsupported filter is named in its warning (#1598); an artboard exported on its own (Export As with Use Artboards, Export for Screens, Asset Export) has the artboard's name as `<title>`, Save the document's (#3544); attributes in other applications' XML namespaces (`plotter:pen="2"`) kept on their objects from import to export, as `data-*` attributes are (`crates/svg/tests/it/foreign_attributes.rs`); simple shapes export as `<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polygon>` and `<polyline>` where they draw the same as the path (#4652, `crates/svg/tests/it/basic_shapes.rs`) |
|
||||
| DXF | open, export | yes | yes (R12–2018) | R13–2018 closed SPLINE flags and control-point closure on import verified (#1299); circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE while non-circular curves keep the existing fallback (`crates/cad/src/tests.rs`); [Autodesk group 70](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-DXF/files/GUID-E1F884F8-AA90-4864-A215-3182D47A9C74.htm). DXF 2000–2018 LAYOUT objects write the paper image origin as 148/149 (was a stray 158), the shade plot codes (2004+) and the model type flag, and the plot style placeholder carries no subclass marker (#3085; structure test `r2000_and_later_files_have_the_structure_cad_apps_require`, checked with ezdxf's audit; not yet confirmed in Autodesk Fusion). CNC preset and VCarve QA remain open |
|
||||
@@ -305,6 +306,9 @@ An object in a pattern's art or in a brush's art takes the file's art styles in
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-06 | minor | EPS import: composite (Type 0) fonts with the 8/8 mapping (`FMapType` 2: PLRM 3rd ed., §5.10) draw a Type 42 descendant's glyphs from the TrueType outlines its `sfnts` carry (Adobe Technical Note #5012), spaced by their own widths or the show operator's numbers, and `charpath` and `stringwidth` read them; descendants found by name set their characters as type at the composite font's size instead of a thousand times it. EPS from `pdftops` of a PDF with embedded TrueType fonts showed nothing, or one black shape, where its type was. |
|
||||
| 2026-11-07 | minor | EPS and `.ai` editing data: opacity mask art that sets no paint of its own paints with the paint before the mask's dictionary, not the black a dictionary's state starts with. A logo of white letters masked by a rectangle with no colour no longer opens hidden: a user's `.ai` (used locally, not committed) shows its six logos. Scores and estimates unchanged. |
|
||||
| 2026-11-06 | minor | EPS and `.ai` editing data: an art style's stroke aligned inside or outside its path (Stroke panel › Align Stroke, written as its own `Adobe Stroke Offset` filter) comes in as a stroke with that alignment, so objects whose style has one keep the style live instead of coming in as their drawn look. Without its inside/outside switch the drawn look stays. |
|
||||
| 2026-11-06 | minor | EPS and `.ai` editing data: `Q` restores the painting state but not the transparency (`Xy`), which the writer only sets when the next object's differs from the last one written. A Screen glow followed by a clipping group set back to Normal no longer leaves the type after the group in Screen: two users' Illustrator 26.5 and 30 EPS posters (used locally, not committed) now show their gold heading and crescent, and both open from their layers (2.5% and 2.7% from the page before, under the limit after). Scores and estimates unchanged. |
|
||||
| 2026-11-06 | minor | EPS and `.ai` editing data: a blend whose spine was never edited, written with its keys and no spine part, comes in as a live blend along the straight lines between its keys' centres (in its record's order) instead of as its drawn steps. Checked on six users' Illustrator EPS files (used locally, not committed): every blend in four of them now comes in live, their renders unchanged; two still have blends the reader can't match to their drawn steps. Scores and estimates unchanged. |
|
||||
| 2026-11-06 | minor | EPS and `.ai` editing data: the art styles are found before the art is read, so objects in symbols defined ahead of `%AI9_BeginArtStyles` (as Illustrator 24 writes it) come in as themselves with their style instead of their drawn look. Checked on two users' EPS files (used locally, not committed): one's Symbols panel gains live Feather and Round Corners, its drawn-look count drops from 5 to 2, and both render unchanged; own-fixture test in `import::ai::tests`; scores and hours unchanged |
|
||||
|
||||
+5
-2
@@ -1,6 +1,6 @@
|
||||
# Roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** minor (Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
## Feature submission evidence
|
||||
|
||||
@@ -36,12 +36,14 @@ M7 type: Touch Type moves a right-to-left character along its line and up from i
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-08 | minor | Character Styles panel: a double-click on a user style's row opens its Character Style Options (`ui.charStyleOptions`); the dialog keeps a fixed width and scrolls its sections in a shorter window, so OK and Cancel stay on screen. Tests `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options` and `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. Scores and estimates unchanged. |
|
||||
| 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. |
|
||||
| 2026-11-07 | minor | Indian-language hyphenation (#4947): eleven MIT-licensed pattern sets, with grapheme and shaping-cluster protection in both composers and existing paragraph limits. Assamese and Sanskrit are selectable; Sanskrit automatic patterns remain pending an allowlisted source. Scores and estimates unchanged. |
|
||||
| 2026-11-05 | minor | Effect dialogs: a colour swatch beside a hex colour field follows typed values, and a double-click opens the Color Picker over the dialog, whose OK fills the field (#5365); scores and estimates unchanged. |
|
||||
| 2026-11-05 | minor | Selection Tools add-on: Select Linked, Embedded, Missing and Modified select the images and placed documents with that link status (Object › Plug-ins › Selection Tools and the Links panel menu; Shift adds), and Select by Link Status… lists them by name, status and format to pick from, optionally zooming to them; what is hidden, locked, on a template layer or outside the isolated group is left out and the status bar says how many (#5526, a VectorCraft addition); scores and estimates unchanged (estimated). |
|
||||
| 2026-11-05 | minor | Pencil and Paintbrush: a new stroke with Keep selected off leaves nothing selected, the earlier selection included, while Keep selected on selects only the new path (#5752); scores and estimates unchanged. |
|
||||
| 2026-11-05 | minor | Layers panel: a layer whose Layer Options › Print is off shows its name italic, as the reference application shows non-printing layers, and keeps it while the row is dragged (#5739); scores and estimates unchanged. |
|
||||
| 2026-11-04 | minor | M7: Select Text by Font takes a scope: the document, chosen artboards or chosen layers; Find Font's Find In offers Document, Artboard (the active one) and Layers (the selected ones, else the current one) (#5529, a VectorCraft addition); estimates unchanged. |
|
||||
| 2026-11-04 | minor | M7: Find Font's Replace With Font lists family and style rows with a search, and the list and the document's font list scroll on their own (#4972); scores and estimates unchanged. |
|
||||
| 2026-11-04 | minor | Selection Tools add-on: Select by Artboards selects the objects overlapping, fully inside or fully outside several chosen artboards, from a dialog that lists, searches and multi-selects the artboards and fits one in the window on double-click (#5525, #5548, #5549, a VectorCraft addition); scores and estimates unchanged. |
|
||||
| 2026-11-04 | minor | M4: raster/PSD exports default to document resolution; CLI run/convert expose --ppi, matching the engine and MCP precedence. Scores and hours unchanged. |
|
||||
@@ -120,7 +122,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l
|
||||
|---|---|---|---|
|
||||
| M0 | Skeleton + vertical slice | ✅ done | — |
|
||||
| M1 | Selection, transform, layers, MCP (common command tools, strict keys, session resources and version-aware result hints) | ✅ mostly done (document-tab artwork drag with lossless resource transfer and one target Undo; malformed MCP app replies discard the connection without replay, #1761; bounded control socket waits and connection-slot recovery, #1726; bounded synthetic control input, #1709; no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending; the Transform fields take one Tab each, the W/H link after them) | 4–6 |
|
||||
| M2 | Drawing tools + smart guides | ✅ mostly done (Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending); a shape just drawn moves by its centre point and turns from the zone outside a corner with its tool still active (#5477); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537); no View submenu | 12–18 |
|
||||
| M2 | Drawing tools + smart guides | ✅ mostly done (Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending); a shape just drawn moves by its centre point and turns from the zone outside a corner with its tool still active (#5477); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537); no View submenu; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123) | 12–18 |
|
||||
| M2 | Drawing tools + smart guides | ✅ mostly done (Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537); no View submenu | 12–18 |
|
||||
| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit; finite-arithmetic follow-up regressions and full CI passed ([Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area))), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls | 4–8 |
|
||||
| M2 | Drawing tools + smart guides | ✅ mostly done (Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending) | 12–18 |
|
||||
@@ -181,6 +183,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-07 | minor | M2: with the Pen or Curvature tool, Edit › Copy and Cut take the anchors and segments a Cmd/Ctrl-lent Direct Selection picked, not the whole path (#6123); estimates unchanged |
|
||||
| 2026-11-06 | minor | M2: a shape just drawn moves by its centre point (Show Center) and turns from the rotate zone just outside a bounding-box corner with its tool still active, as the Selection tool's gestures do (#5477); estimates unchanged. |
|
||||
| 2026-11-06 | minor | Hindi (`hi`): 7,877-row machine-assisted catalog, zero/one singular plurals, desktop font fallback and optional Devanagari craft-fonts fallback; all 55 translation tests pass; native review and a web font remain; scores and estimates unchanged |
|
||||
| 2026-11-04 | minor | New Document: the units chosen in Preset Details belong to the chosen preset; Recent and Saved cards keep their own units; scores and estimates unchanged. |
|
||||
|
||||
File diff suppressed because one or more lines are too long
+12
-1
@@ -17,6 +17,14 @@ Preview changes are temporary until OK; Cancel restores the document; OK is one
|
||||
3D effect: edit its options in the Appearance panel or the 3D and Materials panel, or remove it before applying
|
||||
another.
|
||||
|
||||
Each solid follows its shape as it is filled. Separate and nested contours are built as drawn. Contours that cross,
|
||||
touch or run along each other or themselves, as type often draws a glyph, are first merged into one outline by
|
||||
[`vectorcraft-pathops`](../crates/pathops/README.md), keeping every contour but the numerical debris a boolean leaves.
|
||||
Whether contours meet is judged on the outline as it is followed (see the curve tolerance below), so curves that
|
||||
overlap by less than that are built as drawn. All three effects share pathops'
|
||||
[limits](../crates/pathops/README.md#limits): a shape it refuses is built from its contours as drawn, where it can be.
|
||||
An open subpath is closed by a straight line either way.
|
||||
|
||||
## 3D and Materials panel
|
||||
|
||||
**Window › 3D and Materials** opens a panel that edits the 3D effect of the selection without a dialog, as the
|
||||
@@ -228,7 +236,9 @@ out, perspective). No Adobe software, assets, presets or bevel shapes were used.
|
||||
effect per object.
|
||||
- Curves are followed within 1/2000 of the shape's size (at least 0.05 pt), so a small curve takes a few points
|
||||
and a large one up to 32. Extrude & Bevel and Inflate take up to 2,048 outline points (a stroke's outline is
|
||||
counted on its own), Rotate (which only turns the outline) 65,536.
|
||||
counted on its own), Rotate (which only turns the outline) 65,536. The limit is counted on the shape as drawn and,
|
||||
when its contours meet, 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 for the effect.
|
||||
- **3D (Classic)** (the track cube, surface types, blend steps, map art), Turntable and 3D export are separate,
|
||||
unbuilt items.
|
||||
|
||||
@@ -238,6 +248,7 @@ Release measurements and a repeatable CLI scene are in [3D lighting performance]
|
||||
|
||||
| Date | Change |
|
||||
|---|---|
|
||||
| 2026-11-08 | Contours that cross, touch or overlap are merged by vectorcraft-pathops before Extrude & Bevel, Inflate and Rotate build a solid, sharing its limits; others are built as drawn. |
|
||||
| 2026-11-06 | Extrude & Bevel, Inflate and Rotate apply to groups and layers: every path, compound path and type in them turns about one shared axis (#4506). |
|
||||
| 2026-10-21 | Four original lighting presets, shared by all four 3D effects and commands, with effective-setting recognition and existing preview/history behavior. |
|
||||
| 2026-10-17 | Smooth colored Inflate lighting and surface batching; legacy white-light appearance and the 0.05 seam minimum preserved; shared light Color and multiple lights remain vector output. |
|
||||
|
||||
+7
-4
@@ -1,6 +1,6 @@
|
||||
# Type parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (Typographer's Quotes sets a lone typed `”` as the opening quote where one opens, for the macOS Serbian Cyrillic layout, which has no straight quote, #5247; Indian-language hyphenation review and license allowlist, #4947; Bulgarian among the languages, with its quote marks and the fonts' Bulgarian forms, #5945; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** minor (Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in
|
||||
[target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the
|
||||
@@ -29,12 +29,12 @@ against Illustrator's public documentation; **estimated** unless noted.
|
||||
| Threaded text, Fit Headline, Text Wrap | done | Fit Headline sees wrapping in a frame one line tall (#1444); a thread holds area type and type on a path in any mix, as Illustrator links type in an area or along a path ([help](https://helpx.adobe.com/illustrator/using/manage-text-area.html)) |
|
||||
| Threading from the ports: selected area type and type on a path show an in port and an out port, empty, with an arrow on a side where the frame is threaded, or with a red "+" in the out port when the text doesn't fit; a click on either port loads the text; a click on a closed path, area type or type on a path threads into it, a click on empty canvas makes a frame of the same kind, shape and size there, a drag draws a rectangular frame; the frame placed comes after the clicked one from its out port and before it from its in port (the story then starts there); Escape or another tool cancels; a double-click on a threaded port breaks the thread there | partial | #2572: the Selection tool, `text.thread.add {from, to?, rect?, at?, before?}`, as the reference app's help describes ports and threading ([archived help, Threading text between objects](https://web.archive.org/web/20210119051048/https://helpx.adobe.com/illustrator/using/creating-text.html), [create text threads](https://helpx.adobe.com/illustrator/desktop/design-with-text/add-manage-text/create-text-threads-between-objects.html)); area type's in port sits on the left side below the top-left corner, type on a path's just before its start bracket; Show Text Threads draws its lines between the same ports; with more than 1,000 such objects selected no ports are drawn. a double-click on a port where the frame is threaded breaks the thread there, the story flowing into the frames before the break and the frames after it left empty, still threaded with each other when two or more (`text.thread.break`, [archived help](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html)). Remaining: the ports only take clicks with the Selection tool, and the loaded cursor borrows the I-beam until it's drawn |
|
||||
| Type on a Path effects and options; brackets | done | #429; Type on a Path Options has a Preview checkbox (#5360, as public tutorials of the dialog show) |
|
||||
| Character and Paragraph panels, styles with overrides | done | Character-style Auto-leading resets, partial definitions, local leading overrides, override markers, Undo/Redo and native serialization have regression coverage; differing paragraph alignments leave the shared alignment buttons unselected (headless regressions, full integration CI and visual review pass); mixed-value display for other paragraph fields remains missing |
|
||||
| Character and Paragraph panels, styles with overrides | done | Character-style Auto-leading resets, partial definitions, local leading overrides, override markers, Undo/Redo and native serialization have regression coverage; differing paragraph alignments leave the shared alignment buttons unselected (headless regressions, full integration CI and visual review pass); mixed-value display for other paragraph fields remains missing. Double-clicking a user character style's row opens its Character Style Options, and the dialog keeps a fixed width while its sections scroll when the window is shorter, so its buttons stay on screen; a paragraph style's row opens its Paragraph Style Options, the rename dialog the panel menu opens. Evidence: `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options`, `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list` |
|
||||
| Copy or Cut selected characters, then paste as separate text art | done | A Type-range copy keeps the selected characters' font family, size and color when pasted on the canvas after leaving text editing, in the same document or another. On desktop the clipboard offers plain text to text consumers and rich art for canvas Paste; Paste Without Formatting while editing uses the destination text's style. `tests::clipboard::type_range_copy_stays_styled_when_pasted_as_art` (#5636) |
|
||||
| Character size and horizontal scale after object/group scaling | done | #1034: fields and setters compensate the stored affine; point size follows the transformed em-height axis, not the glyph ink bounds. Range edits, keyboard increments, leading and baseline shift use document points; rotation, reflection and shear remain editable. |
|
||||
| Preserve font sizes while scaling a group | missing | Separate enhancement; scaling still scales text with the other artwork. |
|
||||
| OpenType panel, Glyphs panel, alternates | done | Highlight Alternate Glyphs preference (#394, off by default) paints pink behind OpenType substitutions on screen when on (required shaping such as Arabic joining forms and the lam-alef ligature, including a lam-alef older fonts file under `liga`, is not highlighted; an optional feature that changes the ligature is); stylistic sets ss01–ss20, stylistic and titling alternates, proportional and lining figures, superscript and subscript are character-style features (`text.setStyle {features}`, #1607); the OpenType panel's Position menu sets superscript, subscript, numerator or denominator glyphs (`sups`, `subs`, `numr`, `dnom`). The OpenType panel's Stylistic Sets menu lists the selected font's `ss01`–`ss20` features, using a font-supplied name where available and `Set N` otherwise; several sets can be active, and selecting an active one removes it. For a mixed-font selection it uses the first selected character's font, a VectorCraft design choice ([public help](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/add-stylistic-sets-to-selected-text.html), [OpenType feature specification](https://learn.microsoft.com/en-us/typography/opentype/spec/features_pt)). The Glyphs panel highlights the character selected with the Type tool (a range's first character, else the one before the caret) and scrolls it into view as the selection moves (#4908; `panels::glyphs` tests); its Show menu lists Unicode ranges and Alternates for Current Selection, the selected character's alternate glyphs from the font's `GSUB` table, one of which a double-click draws for that character (`text.setGlyphAlternate`); the glyph-cell pop-up of alternates is not there yet |
|
||||
| Font menu: samples, live preview, filters, favourites, recent families; font matching by any name; Find Font | done | #3515: recently applied families appear first, once each; missing families are omitted and the existing Number of Recent Fonts setting applies; #339; script filter: Japanese, Latin, Thai; the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux) Replace With Font lists family and style rows, one click setting both, with a search over them; its list scrolls on its own, apart from the document's font list (#4972). |
|
||||
| Font menu: samples, live preview, filters, favourites, recent families; font matching by any name; Find Font | done | #3515: recently applied families appear first, once each; missing families are omitted and the existing Number of Recent Fonts setting applies; #339; script filter: Japanese, Latin, Thai; the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux); Select Text by Font takes a scope: the document, chosen artboards or chosen layers; Find Font's Find In offers Document, Artboard (the active one) and Layers (the selected ones, else the current one) (#5529, a VectorCraft addition); Replace With Font lists family and style rows, one click setting both, with a search over them; its list scrolls on its own, apart from the document's font list (#4972). |
|
||||
| Missing glyphs set in an installed font made for their script (Thai, Lao, Khmer, Myanmar first; then any font that has them) | done | Thai: Leelawadee UI, Tahoma, Thonburi, Sukhumvit Set, Noto Sans Thai, TLWG |
|
||||
| Full font-name matching (OpenType name ID 4) | done | #1258: loaded/cataloged faces and Missing Fonts searches; localized names and variable default faces; Windows font-service QA pending |
|
||||
| Variable fonts | done | named instances as styles; Character panel › Variable Font sets each axis (`fontAxes`); the font menu's Font Technology › Variable lists only variable font families, with the script, kind and ★ filters ([Find, apply, and adjust variable fonts](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/find-apply-and-adjust-variable-fonts.html)); a family with a variable file beside static ones counts as variable. Evidence: `font_menu::tests::the_variable_filter_lists_only_variable_families`, `tests_axes::a_family_with_variation_axes_is_variable`, `tests_sysfonts::a_family_with_a_variable_file_beside_static_ones_is_variable` |
|
||||
@@ -52,7 +52,7 @@ against Illustrator's public documentation; **estimated** unless noted.
|
||||
| Hidden characters (Show Hidden Characters) | done | `type.hiddenCharacters` |
|
||||
| Spell check (Check Spelling, Auto Spell Check, custom dictionary) | missing | menu stubs; needs an openly licensed dictionary |
|
||||
| Smart Punctuation, Change Case, Find & Replace | done | |
|
||||
| Alt + numeric-keypad codes while typing (Windows: `Alt+0176` → °) | partial | #5108: on Windows the character Windows posts when Alt comes up after keypad digits is typed at the caret like any other, in the typing's undo step, and the keypad digits typed with Alt run no shortcut (`windows_alt_codes` tests; not yet tried on a Windows machine). `type.insert {code}` (Windows Alt+0 code, Windows-1252 for 128–159) and `{unicode}` (hex code point) insert a character by its code (`menucmds::insert_a_character_by_its_code`). macOS and Linux have no such system convention and egui reports a keypad digit as the top-row digit, so there it does nothing; a floating panel window doesn't receive the character |
|
||||
| Alt + numeric-keypad codes while typing (Windows: `Alt+0176` → °) | partial | #5108: on Windows the character Windows posts when Alt comes up after keypad digits is typed at the caret like any other, in the typing's undo step, and the keypad digits typed with Alt run no shortcut. With NumLock off the keypad keys (Insert, End, arrows, Clear, Home, Page Up/Down without the extended-key flag) count as digits too and don't move the caret (`windows_alt_codes` unit tests; neither NumLock on nor off has been tried on a Windows machine yet). `type.insert {code}` (Windows Alt+0 code, Windows-1252 for 128–159) and `{unicode}` (hex code point) insert a character by its code (`menucmds::insert_a_character_by_its_code`). macOS and Linux have no such system convention and egui reports a keypad digit as the top-row digit, so there it does nothing; a floating panel window doesn't receive the character |
|
||||
| Caret: the arrows and Delete step over whole clusters (Thai consonant with its marks, combining accents, emoji); Backspace removes one character | done | in text with right-to-left characters the arrows move in visual order and stop on both sides of a character drawn with no width, such as a zero width non-joiner |
|
||||
| Touch Type tool | done | moves, scales and rotates one character through its kerning, baseline shift, scales and rotation (`text.touchType`); a character with a preceding logical character on the same line can move along the line in either reading direction; the first logical character moves only up and down. Positive `dx` follows reading order. These direction rules are VectorCraft's choices. |
|
||||
| Retype (identify fonts in outlines/images) | missing | Illustrator's uses a cloud model |
|
||||
@@ -82,11 +82,14 @@ Character Styles menu New/Redefine and footer New capture the first selected cha
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-08 | minor | Style panels: a double-click on a user character style's row opens its Character Style Options (`ui.charStyleOptions`), and on a paragraph style's row its Paragraph Style Options (rename); the Character Style Options dialog keeps a fixed width and scrolls its sections in a shorter window, so OK and Cancel stay on screen. Tests `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options` and `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. Scores and estimates unchanged. |
|
||||
| 2026-11-07 | minor | Alt + numeric-keypad codes with NumLock off: the keypad keys arrive as Insert, End, arrows, Clear, Home and Page Up/Down, which Windows still composes into the code; with Alt held they now count as digits and are taken out of the frame instead of moving the caret, while the separate navigation keys (extended-key flag) keep their meaning (follow-up to #5939). Scores and estimates unchanged. |
|
||||
| 2026-11-07 | minor | Indian-language hyphenation (#4947): eleven MIT-licensed pattern sets, with grapheme and shaping-cluster protection in both composers and existing paragraph limits. Assamese and Sanskrit are selectable; Sanskrit automatic patterns remain pending an allowlisted source. Scores and estimates unchanged. |
|
||||
| 2026-11-06 | minor | Alt + numeric-keypad codes (`Alt+0176` → °) type their character at the caret on Windows, and `type.insert` inserts a character by `code` or `unicode` (#5108); scores and estimates unchanged |
|
||||
| 2026-11-06 | minor | Snap to Glyph › Glyph Bounds: each glyph's box gives four corners a drawn point lands on, also on a curved side (`B`), and its box and edges turn with rotated type (#5112); scores and estimates unchanged |
|
||||
| 2026-11-05 | minor | Character panel › Show Snap to Glyph Options scrolls the panel down to the Snap to Glyph section, which comes after every character attribute and could sit out of sight, and the section starts with a Snap to Glyph checkbox (View › Snap to Glyph) so its dimmed options can be turned on there (#5273); scores and estimates unchanged |
|
||||
| 2026-11-05 | minor | Release Type on a Path joins the Type Tools add-on: type on a path splits into point type and its path (#5532, beyond Illustrator). Scores and estimates unchanged. |
|
||||
| 2026-11-04 | minor | Select Text by Font takes a scope: the document, chosen artboards or chosen layers; Find Font's Find In offers Document, Artboard (the active one) and Layers (the selected ones, else the current one) (#5529, a VectorCraft addition); scores and estimates unchanged. |
|
||||
| 2026-11-04 | minor | Typographer's Quotes: a lone typed `”` (U+201D) is set like a straight `"` where a quote opens (at the start, after a space), taking the document's opening quote, and stays `”` where one closes: the macOS Serbian Cyrillic layout has no straight quote and types `”` on Shift+2, which now gives „…” (#5247). `“ „` and the single quotes `‘ ’ ‚` stay as typed (that layout's apostrophe key types `’`, and `’90s` keeps its apostrophe); pasted text keeps its quotes; with the option off `”` stays as typed. Evidence: `docsetup::a_typed_closing_double_quote_opens_where_a_quote_opens`; scores unchanged |
|
||||
| 2026-11-04 | minor | A Type-range Copy or Cut publishes styled art alongside plain text, so a later canvas Paste keeps its character formatting after editing ends (#5636). Scores and estimates unchanged. |
|
||||
| 2026-11-03 | minor | Glyphs panel adds Alternates for Current Selection. A chosen alternate changes one character's drawn glyph while keeping its Unicode text; character-style capture does not copy that per-character choice. Scores and estimates unchanged. |
|
||||
|
||||
+7
-3
@@ -1,6 +1,6 @@
|
||||
# UI and interaction parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Selection QoL: marquee modes and visible feedback, #5313; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** minor (Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged)
|
||||
|
||||
## Feature submission evidence
|
||||
|
||||
@@ -39,6 +39,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-08 | minor | Style panels: a double-click on a user character style's row opens its Character Style Options (`ui.charStyleOptions`), and on a paragraph style's row its Paragraph Style Options (rename); the Character Style Options dialog keeps a fixed width and scrolls its sections in a shorter window, so OK and Cancel stay on screen. Tests `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options` and `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. Scores and estimates unchanged. |
|
||||
| 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. |
|
||||
| 2026-10-30 | minor | Home screen: welcome, New / Open and Community are centred and the large Discord button uses the theme accent, part of #4684; scores and estimates unchanged (estimated) |
|
||||
| 2026-11-06 | minor | Snap to Grid: a straight segment dragged with Direct Selection lands its anchors on the grid, not the pressed point (#3835); scores and estimates unchanged |
|
||||
@@ -131,7 +132,7 @@ tools, never checked side by side) isn't captured row by row.
|
||||
| Click the first anchor to close; the drag shapes the closing curve | done | |
|
||||
| Click the last anchor to retract its outgoing handle; drag from it to pull a new one | done | |
|
||||
| Alt over a handle, anchor or segment acts as the Anchor Point tool | done | |
|
||||
| Cmd/Ctrl lends the last-used selection tool | done | the path being drawn continues afterwards; with Direct Selection (the default until a selection tool is chosen) a Cmd drag moves an anchor of the path being drawn, moves a straight segment or bends a curved one, one undo step each, and a Cmd-click away from art ends the path; with Selection the hidden bounding box of the path being drawn takes no press, so the drag moves the path (#4980) |
|
||||
| Cmd/Ctrl lends the last-used selection tool | done | the path being drawn continues afterwards; with Direct Selection (the default until a selection tool is chosen) a Cmd drag moves an anchor of the path being drawn, moves a straight segment or bends a curved one, one undo step each, and a Cmd-click away from art ends the path; with Selection the hidden bounding box of the path being drawn takes no press, so the drag moves the path (#4980); anchors or segments the lent Direct Selection picks are what Copy and Cut take, as with Direct Selection chosen, while Copy with the Pen's own last anchor selected takes the whole path (#6123) |
|
||||
| Click an open path's end to continue it; click another path's end to join | done | #776 |
|
||||
| Rubber Band preview | done | Preferences option |
|
||||
| Auto Add/Delete on a selected path; Shift or the preference disables it | done | |
|
||||
@@ -191,7 +192,7 @@ tools, never checked side by side) isn't captured row by row.
|
||||
| Alt with Direct Selection makes a marquee Group Selection's for the drag: it selects every path it touches, whole (Shift toggles them), and the Group Selection pointer stays until the release | done | #4890 |
|
||||
| Direct Selection: a click on a selected object's centre point (Attributes › Show Center) selects the object whole, and a drag from it moves the object in one undo step | done | #4891 |
|
||||
| Escape cancels a Direct Selection drag | done | #1803 (#1743) |
|
||||
| Live Corners widgets (radius readout, Alt-click cycles kind, double-click the Corners dialog) | done | a Direct Selection click on a widget selects its corner (#1239); a press-and-drag on a widget of a path selected whole still rounds every corner (#908), and adding widgets to the selection by Shift-click (#1232) is missing; the bounding box hides while a widget is dragged with the Selection tool (#1229); a round corner next to a curved side curves smoothly instead of sticking out of the shape (#1512); a corner rounds until its cut reaches its neighbours' cuts, so one corner of a square alone reaches the whole side (#2332) |
|
||||
| Live Corners widgets (radius readout, Alt-click cycles kind, double-click the Corners dialog) | done | a Direct Selection click on a widget selects its corner (#1239), and with every corner selected a Shift-click on a widget picks that corner alone instead of deselecting it (#5188); a press-and-drag on a widget of a path selected whole still rounds every corner (#908), and adding widgets to the selection by Shift-click (#1232) is missing; the bounding box hides while a widget is dragged with the Selection tool (#1229); a round corner next to a curved side curves smoothly instead of sticking out of the shape (#1512); a corner rounds until its cut reaches its neighbours' cuts, so one corner of a square alone reaches the whole side (#2332) |
|
||||
| Corners between curved sides (#1144), Relative rounding | done | #1144 closed; Relative rounding in the Corners dialog (Relative / Absolute, radius as a percentage of each corner's maximum, kept as the shape is resized; `object.setLiveShape` `relativeRadii`, #3091, `tests_livecorners::relative_corner_rounding_keeps_fraction_on_resize`) |
|
||||
| Pie widgets on a live ellipse: drag one to set Pie Start or End Angle, double-click one to make the ellipse whole | done | Selection tool, with the bounding box showing |
|
||||
| A shape just drawn shows its bounding box and Live Shape widgets with the shape tool still active, and the selection can be moved and turned without switching tools | done | #5477: Rectangle, Rounded Rectangle, Ellipse, Polygon and Star keep the selection's bounding box, corner widgets and pie widgets on show while they draw nothing; a drag on a corner or pie widget adjusts it (arrows step the corner kind, Escape cancels), a press on the selection's centre point (Attributes › Show Center) moves the selection — the Selection tool's move arrow, snapping and Alt copy — a press just outside a corner of the box turns it about the centre (the Selection tool's rotate zone and cursor), and a press anywhere else draws (so a second shape drawn from the first one's centre moves the first: press elsewhere or deselect first). Public descriptions: [Live Shapes](https://helpx.adobe.com/illustrator/using/live-shapes.html) (live shapes adjust without switching tools) and [CreativePro, Working with Live Shapes](https://creativepro.com/?p=23921) (a widget shows in every corner on release; drag one toward the centre to round). The bounding-box handles show but scale only with the Selection tool, so a press on a corner still starts a new shape there; double-clicking a widget opens nothing with a shape tool. Tests `a_shape_tool_drags_the_selected_shapes_widgets` and `a_shape_tool_moves_the_selection_by_its_centre_point_and_turns_it_from_a_corner` |
|
||||
@@ -436,6 +437,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po
|
||||
| Color Effects add-on (VectorCraft addition): Object › Plug-ins › Color Effects › Find Nearest Swatch… picks a Library (Document Swatches, a built-in library, or a library or colour book the user saved or loaded) and Fill and Stroke, lists each colour of the selection with its three closest swatches (chip, name, ΔE 2000), and Apply gives each colour its nearest swatch (global and spot swatches linked; a library's swatches added to the document first), one undo step | done | #4923; `addon.color.nearestSwatch`, `addon.color.applyNearestSwatch`, `crates/engine/src/cmd/addons/color/nearest.rs`, `crates/ui-egui/src/addons/color/nearest.rs` and their tests; not a feature of the reference app, so it is not scored |
|
||||
| Selection Tools add-on (VectorCraft addition): Object › Plug-ins › Selection Tools › Select by Artboards… lists every artboard (number, name, width × height in the document's units), filters them by name, chooses several (click, Shift-click for a range, Cmd/Ctrl-click to toggle; Select All / None act on the rows shown; a count of those chosen), fits a row's artboard in the window on double-click and makes it active, and on OK selects the objects overlapping, fully inside or fully outside the chosen artboards (Overlapping is All on Active Artboard's test) | done | #5525, #5548, #5549; `addon.select.byArtboards`, `crates/engine/src/cmd/addons/select.rs`, `crates/ui-egui/src/addons/select.rs` and their tests; not a feature of the reference app |
|
||||
| Selection Tools add-on (VectorCraft addition): Select Linked, Select Embedded, Select Missing and Select Modified in Object › Plug-ins › Selection Tools and the Links panel menu (off when no image has that status; Shift adds to the selection) select the images and placed documents with that link status, leaving out hidden, locked, template-layer and outside-isolation ones, which the status bar counts (e.g. "Selected 9 missing images; 3 more are hidden or locked"); Select by Link Status… lists them with name, status and format, searches by name, filters by status, picks with checkboxes and All / None, and selects with Zoom to Selection on by default | done | #5526; `addon.select.byLinkStatus`, `crates/engine/src/cmd/addons/select.rs`, `crates/ui-egui/src/addons/select/link_status.rs` and their tests; not a feature of the reference app |
|
||||
| Style panels: a double-click on a user character style's row opens its Character Style Options, and on a paragraph style's row its Paragraph Style Options (rename), and the Character Style Options dialog keeps a fixed width, its sections scrolling when the window is shorter so OK and Cancel stay on screen | done | `ui.charStyleOptions`, `ui.paramDialog` (`paraStyle.rename`); `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options`, `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list` |
|
||||
|
||||
## Look and feel
|
||||
|
||||
@@ -480,6 +482,8 @@ separate. Scores and hours unchanged. [Behavior and command details](development
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-07 | minor | Pen and Curvature tool with Cmd/Ctrl held: Edit › Copy and Cut take the anchors and segments the lent Direct Selection picked, as with Direct Selection chosen, not the whole path; with the Pen's own last anchor selected Copy still takes the whole path (#6123); scores and estimates unchanged |
|
||||
| 2026-11-07 | minor | Live Corners: with Direct Selection, while every corner of a path is selected, a Shift-click on a corner widget picks that corner alone instead of deselecting it (#5188); adding a hidden corner's widget by Shift-click stays open (#1232); scores and estimates unchanged |
|
||||
| 2026-11-06 | minor | Keyboard shortcuts: Cmd/Ctrl+Alt+. and Cmd/Ctrl+Alt+, (Stroke Weight Up and Down) step each selected stroke's weight, or the next object drawn's (#5797); scores and estimates unchanged |
|
||||
| 2026-11-06 | minor | Properties: Quick Actions has Repeat Options for one selected radial, grid or mirror repeat, opening Object › Repeat › Options with its values (#5181); scores and estimates unchanged |
|
||||
| 2026-11-06 | minor | Control bar: with type selected, a Paragraph: link (the Paragraph panel in a popover) and the Align left, Align center and Align right buttons follow the Character controls; the bar leaves them out from its right end when short of room, after X/Y/W/H and the Align buttons (#5372); scores and estimates unchanged |
|
||||
|
||||
Reference in New Issue
Block a user