Merge fix/plugin-grant-module-hash into fix/plugin-stale-grant

This commit is contained in:
dotCooCoo
2026-11-08 15:56:42 -08:00
296 changed files with 16073 additions and 1511 deletions
Generated
+1
View File
@@ -6338,6 +6338,7 @@ dependencies = [
"memchr",
"ruzstd",
"serde_json",
"skrifa 0.47.0",
"vectorcraft-brush",
"vectorcraft-color",
"vectorcraft-doc",
+1 -1
View File
@@ -310,7 +310,7 @@ fn pci_ids(device_id: &str) -> Option<(u32, u32)> {
let field = |key: &str| {
device_id.split(['\\', '&']).find_map(|part| {
let (name, hex) = part.split_at_checked(key.len())?;
if name.eq_ignore_ascii_case(key) { u32::from_str_radix(hex, 16).ok() } else { None }
if name.eq_ignore_ascii_case(key) && hex.bytes().all(|b| b.is_ascii_hexdigit()) { u32::from_str_radix(hex, 16).ok() } else { None }
})
};
Some((field("VEN_")?, field("DEV_")?))
+7
View File
@@ -820,3 +820,10 @@ fn crashes_are_forgotten_in_another_version_or_under_another_preference() {
assert_eq!(record.crashed.len(), CRASHED_LIMIT);
assert_eq!(record.crashed.last().map(|c| c.key.as_str()), Some("Dx12:10de:0013"));
}
/// `u32::from_str_radix` accepts a leading `+`; a device id with one is not a PCI id.
#[test]
fn pci_ids_reject_a_sign_in_the_hex_digits() {
assert_eq!(pci_ids("PCI\\VEN_+10DE&DEV_2204"), None);
assert_eq!(pci_ids("PCI\\VEN_10DE&DEV_+2204"), None);
}
+152 -39
View File
@@ -13,6 +13,13 @@
//! same undo step as the typing round it. The keypad digits themselves are taken out of the frame,
//! so they don't run a shortcut bound to Alt and a digit.
//!
//! With NumLock off the keypad keys arrive as Insert, End, Down, Page Down, Left, Clear, Right,
//! Home, Up and Page Up, and Windows still composes the code from them. They are told from the
//! separate navigation keys by the extended-key flag, which only the separate keys set
//! (<https://learn.microsoft.com/en-us/windows/win32/inputdev/about-keyboard-input#extended-key-flag>),
//! so with Alt held they count as digits and are taken out of the frame too, rather than moving
//! the caret before the character arrives.
//!
//! egui reports a keypad digit as the same key as the digit above the letters, so the other
//! platforms, whose systems have no such convention, can't tell them apart either.
@@ -28,10 +35,20 @@ const WM_CHAR: u32 = 0x0102;
const WM_SYSKEYDOWN: u32 = 0x0104;
const WM_SYSKEYUP: u32 = 0x0105;
const WM_SYSCHAR: u32 = 0x0106;
/// `VK_NUMPAD0` to `VK_NUMPAD9` (`winuser.h`): keypad digits with NumLock on. With it off the keys
/// are Insert, End, … and type no code.
/// `VK_NUMPAD0` to `VK_NUMPAD9` (`winuser.h`): keypad digits with NumLock on.
const VK_NUMPAD0: usize = 0x60;
const VK_NUMPAD9: usize = 0x69;
/// The keypad's virtual keys with NumLock off, by digit (`winuser.h`): `VK_INSERT`, `VK_END`,
/// `VK_DOWN`, `VK_NEXT`, `VK_LEFT`, `VK_CLEAR`, `VK_RIGHT`, `VK_HOME`, `VK_UP` and `VK_PRIOR`.
const NUMLOCK_OFF_KEYS: [usize; 10] = [0x2D, 0x23, 0x28, 0x22, 0x25, 0x0C, 0x27, 0x24, 0x26, 0x21];
/// The keys egui reports for the keypad with NumLock off, by digit (Clear has no egui key, so it
/// comes as the top-row digit key, `Num5`).
const NUMLOCK_OFF_EGUI_KEYS: [Key; 10] =
[Key::Insert, Key::End, Key::ArrowDown, Key::PageDown, Key::ArrowLeft, Key::Num5, Key::ArrowRight, Key::Home, Key::ArrowUp, Key::PageUp];
const DIGIT_KEYS: [Key; 10] = [Key::Num0, Key::Num1, Key::Num2, Key::Num3, Key::Num4, Key::Num5, Key::Num6, Key::Num7, Key::Num8, Key::Num9];
/// Bit 24 of a key message's `lParam`: an extended key, such as the navigation keys beside the
/// keypad (the keypad's own keys clear it).
const EXTENDED_KEY: isize = 1 << 24;
/// `VK_MENU`, `VK_LMENU` and `VK_RMENU`: Alt.
const ALT_KEYS: [usize; 3] = [0x12, 0xA4, 0xA5];
@@ -51,23 +68,42 @@ struct AltCodes {
high: Option<u16>,
/// Characters the codes typed, for the next frame.
typed: String,
/// Keypad digit presses and releases with Alt held since the last frame, which egui reports
/// as Alt and a digit: taken out of the frame.
keypad_events: usize,
/// The digits of the keypad presses and releases with Alt held since the last frame, which
/// egui reports as Alt and a digit (or, with NumLock off, Alt and a navigation key): taken
/// out of the frame.
keypad_events: Vec<usize>,
}
/// The digit a keypad key stands for in an Alt code: `wparam` is its virtual-key code and
/// `lparam` the message's flags.
fn keypad_digit(wparam: usize, lparam: isize) -> Option<usize> {
if (VK_NUMPAD0..=VK_NUMPAD9).contains(&wparam) {
return Some(wparam - VK_NUMPAD0);
}
if lparam & EXTENDED_KEY != 0 {
return None;
}
NUMLOCK_OFF_KEYS.iter().position(|&k| k == wparam)
}
impl AltCodes {
/// Note a keyboard message `message` with `wparam` (the virtual-key code, or a UTF-16 unit).
fn message(&mut self, message: u32, wparam: usize) {
let keypad = (VK_NUMPAD0..=VK_NUMPAD9).contains(&wparam);
match message {
/// Note a keyboard message `message` with `wparam` (the virtual-key code, or a UTF-16 unit)
/// and `lparam` (its flags).
fn message(&mut self, message: u32, wparam: usize, lparam: isize) {
let keypad = match message {
WM_SYSKEYDOWN | WM_SYSKEYUP => keypad_digit(wparam, lparam),
_ => None,
};
if let Some(d) = keypad {
// Windows sends a key pressed with Alt held as a system key.
WM_SYSKEYDOWN if keypad => {
if message == WM_SYSKEYDOWN {
self.digits += 1;
self.keypad_events += 1;
self.armed = false;
}
WM_SYSKEYUP if keypad => self.keypad_events += 1,
self.keypad_events.push(d);
return;
}
match message {
WM_KEYDOWN | WM_SYSKEYDOWN if ALT_KEYS.contains(&wparam) => {}
// Any other key ends the sequence and keeps its own meaning.
WM_KEYDOWN | WM_SYSKEYDOWN => self.reset(),
@@ -99,14 +135,23 @@ impl AltCodes {
/// out the keypad digits typed with Alt held.
fn drain(&mut self, events: &mut Vec<Event>) {
let mut keypad = std::mem::take(&mut self.keypad_events);
if keypad > 0 {
if !keypad.is_empty() {
events.retain(|e| {
let digit = matches!(e, Event::Key { key: Key::Num0 | Key::Num1 | Key::Num2 | Key::Num3 | Key::Num4 | Key::Num5 | Key::Num6 | Key::Num7 | Key::Num8 | Key::Num9, modifiers, .. } if modifiers.alt);
if digit && keypad > 0 {
keypad -= 1;
return false;
let Event::Key { key, modifiers, .. } = e else {
return true;
};
if !modifiers.alt {
return true;
}
// The first keypad key still to take out that egui reports as `key`.
let stands_for = |d: &usize| DIGIT_KEYS.get(*d) == Some(key) || NUMLOCK_OFF_EGUI_KEYS.get(*d) == Some(key);
match keypad.iter().position(stands_for) {
Some(i) => {
keypad.remove(i);
false
}
None => true,
}
true
});
}
if !self.typed.is_empty() {
@@ -129,7 +174,7 @@ pub fn msg_hook(msg: *const std::ffi::c_void) {
if (WM_KEYDOWN..=WM_SYSCHAR).contains(&msg.message) {
CODES.with(|c| {
if let Ok(mut c) = c.try_borrow_mut() {
c.message(msg.message, msg.wParam.0);
c.message(msg.message, msg.wParam.0, msg.lParam.0);
}
});
}
@@ -152,14 +197,14 @@ mod tests {
/// Alt held, keypad digits `code`, Alt released, and the character Windows then posts.
fn alt_code(c: &mut AltCodes, code: &str, posted: u16) {
c.message(WM_SYSKEYDOWN, ALT);
c.message(WM_SYSKEYDOWN, ALT, 0);
for d in code.bytes() {
let vk = VK_NUMPAD0 + usize::from(d - b'0');
c.message(WM_SYSKEYDOWN, vk);
c.message(WM_SYSKEYUP, vk);
c.message(WM_SYSKEYDOWN, vk, 0);
c.message(WM_SYSKEYUP, vk, 0);
}
c.message(WM_KEYUP, ALT);
c.message(WM_CHAR, usize::from(posted));
c.message(WM_KEYUP, ALT, 0);
c.message(WM_CHAR, usize::from(posted), 0);
}
fn key(key: Key, alt: bool, pressed: bool) -> Event {
@@ -186,11 +231,11 @@ mod tests {
fn a_character_without_a_code_before_it_is_left_to_winit() {
let mut c = AltCodes::default();
// A plain key press's character (winit types it), and Alt pressed and released alone.
c.message(WM_KEYDOWN, 0x41);
c.message(WM_CHAR, usize::from(b'a'));
c.message(WM_SYSKEYDOWN, ALT);
c.message(WM_KEYUP, ALT);
c.message(WM_CHAR, usize::from(b'b'));
c.message(WM_KEYDOWN, 0x41, 0);
c.message(WM_CHAR, usize::from(b'a'), 0);
c.message(WM_SYSKEYDOWN, ALT, 0);
c.message(WM_KEYUP, ALT, 0);
c.message(WM_CHAR, usize::from(b'b'), 0);
let mut events = vec![];
c.drain(&mut events);
assert!(events.is_empty(), "{events:?}");
@@ -199,12 +244,12 @@ mod tests {
#[test]
fn another_key_while_alt_is_held_cancels_the_code() {
let mut c = AltCodes::default();
c.message(WM_SYSKEYDOWN, ALT);
c.message(WM_SYSKEYDOWN, VK_NUMPAD0 + 1);
c.message(WM_SYSKEYDOWN, ALT, 0);
c.message(WM_SYSKEYDOWN, VK_NUMPAD0 + 1, 0);
// Alt+F: a shortcut, not a code.
c.message(WM_SYSKEYDOWN, 0x46);
c.message(WM_KEYUP, ALT);
c.message(WM_CHAR, 0x41);
c.message(WM_SYSKEYDOWN, 0x46, 0);
c.message(WM_KEYUP, ALT, 0);
c.message(WM_CHAR, 0x41, 0);
let mut events = vec![];
c.drain(&mut events);
assert!(!events.iter().any(|e| matches!(e, Event::Text(_))), "{events:?}");
@@ -213,13 +258,81 @@ mod tests {
#[test]
fn a_code_above_the_basic_plane_arrives_as_two_halves() {
let mut c = AltCodes::default();
c.message(WM_SYSKEYDOWN, ALT);
c.message(WM_SYSKEYDOWN, VK_NUMPAD0 + 1);
c.message(WM_KEYUP, ALT);
c.message(WM_CHAR, 0xD83D);
c.message(WM_CHAR, 0xDE00);
c.message(WM_SYSKEYDOWN, ALT, 0);
c.message(WM_SYSKEYDOWN, VK_NUMPAD0 + 1, 0);
c.message(WM_KEYUP, ALT, 0);
c.message(WM_CHAR, 0xD83D, 0);
c.message(WM_CHAR, 0xDE00, 0);
let mut events = vec![];
c.drain(&mut events);
assert_eq!(events, vec![Event::Text("😀".into())]);
}
/// Alt held, keypad digits `code` with NumLock off (keypad Insert, End, …, flags without the
/// extended-key bit), Alt released, and the character Windows then posts.
fn alt_code_numlock_off(c: &mut AltCodes, code: &str, posted: u16) {
c.message(WM_SYSKEYDOWN, ALT, 0);
for d in code.bytes() {
let vk = NUMLOCK_OFF_KEYS[usize::from(d - b'0')];
c.message(WM_SYSKEYDOWN, vk, 0);
c.message(WM_SYSKEYUP, vk, 0);
}
c.message(WM_KEYUP, ALT, 0);
c.message(WM_CHAR, usize::from(posted), 0);
}
#[test]
fn with_numlock_off_the_keypad_keys_type_the_code_and_leave_the_caret_alone() {
let mut c = AltCodes::default();
alt_code_numlock_off(&mut c, "0176", 0xB0);
// What egui reports for keypad 0, 1, 7 and 6 with NumLock off and Alt held, a press and a
// release each, then a plain Home typed after Alt came up.
let mut events = vec![];
for k in [Key::Insert, Key::End, Key::Home, Key::ArrowRight] {
events.push(key(k, true, true));
events.push(key(k, true, false));
}
events.push(key(Key::Home, false, true));
c.drain(&mut events);
assert_eq!(events, vec![key(Key::Home, false, true), Event::Text("°".into())]);
}
#[test]
fn with_numlock_off_keypad_five_counts_too() {
let mut c = AltCodes::default();
// Keypad 5 is Clear, which egui reports as the top-row 5. 0151 uses Insert, End and Clear;
// 0234 uses Insert, Down, Page Down and Left.
alt_code_numlock_off(&mut c, "0151", 0x2014);
alt_code_numlock_off(&mut c, "0234", 0xEA);
let mut events =
vec![key(Key::Num5, true, true), key(Key::ArrowDown, true, true), key(Key::PageDown, true, true), key(Key::ArrowLeft, true, true)];
c.drain(&mut events);
assert_eq!(events, vec![Event::Text("—ê".into())]);
}
#[test]
fn the_separate_navigation_keys_still_move_the_caret_and_cancel_the_code() {
let mut c = AltCodes::default();
c.message(WM_SYSKEYDOWN, ALT, 0);
c.message(WM_SYSKEYDOWN, NUMLOCK_OFF_KEYS[1], 0);
c.message(WM_SYSKEYUP, NUMLOCK_OFF_KEYS[1], 0);
// Alt+Home on the separate Home key (extended): a shortcut, not a digit.
c.message(WM_SYSKEYDOWN, NUMLOCK_OFF_KEYS[7], EXTENDED_KEY);
c.message(WM_SYSKEYUP, NUMLOCK_OFF_KEYS[7], EXTENDED_KEY);
c.message(WM_KEYUP, ALT, 0);
c.message(WM_CHAR, 0x41, 0);
let mut events = vec![key(Key::End, true, true), key(Key::End, true, false), key(Key::Home, true, true), key(Key::Home, true, false)];
c.drain(&mut events);
assert_eq!(events, vec![key(Key::Home, true, true), key(Key::Home, true, false)]);
}
#[test]
fn numlock_off_keys_without_alt_are_left_alone() {
let mut c = AltCodes::default();
c.message(WM_KEYDOWN, NUMLOCK_OFF_KEYS[7], 0);
c.message(WM_KEYUP, NUMLOCK_OFF_KEYS[7], 0);
let mut events = vec![key(Key::Home, false, true), key(Key::Home, false, false)];
c.drain(&mut events);
assert_eq!(events.len(), 2);
}
}
+40 -2
View File
@@ -29,6 +29,14 @@ const MAX_DOCUMENT_CELLS: usize = 1 << 18;
/// Points of all strokes' pressure curves together (a stroke style may be shared by many layers).
const MAX_DOCUMENT_PRESSURE_POINTS: usize = 1 << 20;
/// Child references inspected, page rectangles produced, and outline segments retained, in all
/// (shared objects are visited once per link, so each visit is charged). Real documents stay
/// within a few times [`MAX_NODES`]; this leaves ~30x headroom over that.
const MAX_DOCUMENT_WORK: usize = 1 << 24;
/// Owned layer-name characters kept in all (each visit of a shared object keeps its own copy).
const MAX_DOCUMENT_NAME_CHARS: usize = 16 << 20;
/// What a whole read may still use. Shared objects can be linked from many layers, and embedded
/// documents are read with what their embedding document left, so linking or embedding the
/// same content many times can't multiply the work.
@@ -44,6 +52,10 @@ pub(crate) struct Budget {
pub(crate) cells: usize,
/// Points of pressure curves ([`MAX_DOCUMENT_PRESSURE_POINTS`] in all).
pub(crate) pressure: usize,
/// Child references, page rectangles and outline segments ([`MAX_DOCUMENT_WORK`] in all).
pub(crate) work: usize,
/// Layer-name characters ([`MAX_DOCUMENT_NAME_CHARS`] in all).
pub(crate) name_chars: usize,
}
impl Budget {
@@ -53,7 +65,15 @@ impl Budget {
chars: MAX_DOCUMENT_CHARS,
cells: MAX_DOCUMENT_CELLS,
pressure: MAX_DOCUMENT_PRESSURE_POINTS,
work: MAX_DOCUMENT_WORK,
name_chars: MAX_DOCUMENT_NAME_CHARS,
};
/// Take `n` units of work, or fail the read.
pub(crate) fn spend(&mut self, n: usize) -> Result<(), Error> {
self.work = self.work.checked_sub(n).ok_or(Error::Limit("too much shared geometry or page data"))?;
Ok(())
}
}
/// Read a document: archive, `doc.dat` and the model.
@@ -192,6 +212,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
return Ok(None);
}
let pages = pages(s, spread);
self.budget.spend(pages.len() + 1)?;
let default_size = s.floats::<2>(doc, b"DfSz").filter(|[w, h]| *w > 0.0 && *h > 0.0).map(|[w, h]| Rect { x0: 0.0, y0: 0.0, x1: w, y1: h });
let bounds = s.floats::<4>(spread, b"SprB").map(rect).or_else(|| pages.iter().copied().reduce(union)).or(default_size).unwrap_or(Rect {
x0: 0.0,
@@ -214,7 +235,10 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
let s = self.s;
let mut out = Vec::new();
let mut mask = None;
for c in s.objs(parent, b"Chld") {
let kids = s.objs(parent, b"Chld");
// Invisible masks and skipped children cost no node, so the visit is charged here.
self.budget.spend(kids.len())?;
for c in kids {
if s.is(c, b"MRst") {
// A mask layer inside a group or layer masks that container.
if s.bool(c, b"Visi") == Some(false) {
@@ -254,7 +278,8 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
let world = local.then(parent);
let class = s.class(id).unwrap_or(Tag(0));
// A layer name is short; a long one is cut, as every visit of a shared object keeps a copy.
let name: String = s.str(id, b"Desc").unwrap_or_default().chars().take(MAX_NAME_CHARS).collect();
let name: String = s.str(id, b"Desc").unwrap_or_default().chars().take(MAX_NAME_CHARS.min(self.budget.name_chars)).collect();
self.budget.name_chars -= name.chars().count();
let visible = s.bool(id, b"Visi").unwrap_or(true);
let locked = s.bool(id, b"Edtb").is_some_and(|e| !e);
let opacity = s.f64(id, b"Opac").map_or(1.0, |o| o.clamp(0.0, 1.0));
@@ -312,6 +337,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
self.warn(format!("a {} that could not be read", describe(class)));
return Ok(Kind::Unsupported);
};
self.budget.spend(local.subpaths.iter().map(|sp| sp.segments.len() + 1).sum())?;
let (fills, strokes) = paint::node_paint(self, id, world);
Kind::Shape { path: local.transformed(world), fills, strokes, even_odd }
}
@@ -645,3 +671,15 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
Kind::Artboard { rect, path: path.transformed(world), even_odd, background, strokes }
}
}
#[cfg(test)]
mod budget_tests {
use super::*;
#[test]
fn shared_work_is_bounded() {
let mut b = Budget::FULL;
assert!(b.spend(MAX_DOCUMENT_WORK).is_ok());
assert_eq!(b.spend(1), Err(Error::Limit("too much shared geometry or page data")));
}
}
+5 -4
View File
@@ -238,12 +238,13 @@ pub(crate) fn descriptor(r: &mut Reader, id: ObjId, local: Affine) -> Option<Pai
};
let grad = s.obj(fill, b"Grad")?;
let cols = s.objs(grad, b"Cols");
let posn: Vec<Vec<f64>> = match s.field(grad, b"Posn") {
Some(Value::Array(a)) => a.iter().filter_map(floats).collect(),
_ => Vec::new(),
// Convert only the positions that pair with a colour, as shared attributes repeat this per run.
let posn: &[Value] = match s.field(grad, b"Posn") {
Some(Value::Array(a)) => a,
_ => &[],
};
let mut stops = Vec::new();
for (c, p) in cols.iter().zip(&posn).take(MAX_STOPS) {
for (c, p) in cols.iter().zip(posn.iter().filter_map(floats)).take(MAX_STOPS) {
let color = color(r, *c)?;
let offset = p.first().copied().filter(|v| v.is_finite()).unwrap_or(0.0).clamp(0.0, 1.0);
let bias = p.get(1).copied().filter(|v| v.is_finite()).unwrap_or(0.5).clamp(0.0, 1.0);
+8 -1
View File
@@ -195,6 +195,11 @@ fn decode_within(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), worl
if let Some(name) = s.entry(bitmap, b"Bckg") {
let width = s.int(bitmap, b"BmpW").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
let height = s.int(bitmap, b"BmpH").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
// The whole source is decoded for every layer that reads it, so charge it all, not only the crop.
let Some(left) = r.budget.pixels.checked_sub(u64::from(width) * u64::from(height)) else {
return Err(Error::Limit("embedded image pixels"));
};
r.budget.pixels = left;
match original(r, name).and_then(|bytes| source_pixels(&bytes, width, height)) {
Ok(pixels) => Some((width as usize, pixels)),
Err(Error::Limit(e)) => return Err(Error::Limit(e)),
@@ -318,8 +323,10 @@ fn grid(s: &stream::Stream, bitmap: ObjId, c: usize, bps: usize) -> (usize, usiz
let height = s.int(bitmap, b"BmpH").and_then(|v| usize::try_from(v).ok()).unwrap_or(0);
let tw = s.int(bitmap, &tag(b"TWi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| width.saturating_mul(bps).div_ceil(TILE));
let th = s.int(bitmap, &tag(b"THi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| height.div_ceil(TILE));
// A grid larger than the bitmap has tiles no crop can reach: don't convert or scan them on every reference.
let tiles = width.saturating_mul(bps).div_ceil(TILE).saturating_mul(height.div_ceil(TILE));
let states: Vec<u8> = match s.field(bitmap, &tag(b"Sta", c)) {
Some(Value::Array(a)) => a.iter().map(|v| if let Value::UInt(u) = v { u8::try_from(*u).unwrap_or(255) } else { 255 }).collect(),
Some(Value::Array(a)) => a.iter().take(tiles).map(|v| if let Value::UInt(u) = v { u8::try_from(*u).unwrap_or(255) } else { 255 }).collect(),
_ => Vec::new(),
};
(tw, th, states)
+15 -4
View File
@@ -51,6 +51,9 @@ const MAX_TABLE_LINES: usize = 4096;
/// Cells a table may have (rows × columns): each becomes a text object with its edge lines.
const MAX_TABLE_CELLS: usize = 1 << 16;
/// Work charged for each copy of a run's formatting a table cell keeps.
const TABLE_RUN_COST: usize = 16;
/// A table node (`TxtT`): the grid, the cells' text and the lines on the cell edges.
pub(crate) fn table(r: &mut Reader, id: ObjId, world: Affine) -> Option<Table> {
let s = r.s;
@@ -108,9 +111,10 @@ pub(crate) fn table(r: &mut Reader, id: ObjId, world: Affine) -> Option<Table> {
let mut parts = run.text.split(CELL_BREAK).peekable();
while let Some(part) = parts.next() {
if !part.is_empty() {
// Each cell keeps its own copy of the run's formatting, so each copy is charged.
match target.and_then(|i| cells.get_mut(i)) {
Some(cell) => cell.runs.push(TextRun { text: part.to_string(), ..run.clone() }),
None => extra = true,
Some(cell) if r.budget.spend(TABLE_RUN_COST).is_ok() => cell.runs.push(TextRun { text: part.to_string(), ..run.clone() }),
_ => extra = true,
}
}
if parts.peek().is_some() {
@@ -157,6 +161,13 @@ pub(crate) fn story_runs(r: &mut Reader, story: ObjId, world: Affine) -> (Vec<Te
let mut placeholders = false;
let mut attribute_budget = Some(MAX_ATTRIBUTE_BYTES);
for block in s.objs(story, b"Blok") {
// Shared blocks and run lists are visited once per link, so every visit is charged, empty or not.
let paragraph_runs = s.obj(block, b"PAtt").map(|p| s.objs(p, b"Runs")).unwrap_or_default();
let attribute_runs = s.obj(block, b"GAtt").map(|g| s.objs(g, b"Runs")).unwrap_or_default();
if r.budget.spend(1 + paragraph_runs.len() + attribute_runs.len()).is_err() {
r.warn("text beyond the work one document may hold (truncated)");
break;
}
let Some(glyphs) = s.obj(block, b"Glyp") else {
r.warn("a text block without readable characters (left out)");
continue;
@@ -209,7 +220,7 @@ pub(crate) fn story_runs(r: &mut Reader, story: ObjId, world: Affine) -> (Vec<Te
// Paragraph formats by end index; a run without an index covers the rest of the block.
let mut paragraphs: Vec<(usize, Paragraph)> = Vec::new();
let mut open: Option<Paragraph> = None;
for run in s.obj(block, b"PAtt").map(|p| s.objs(p, b"Runs")).unwrap_or_default() {
for run in paragraph_runs {
let Some(attrs) = s.obj(run, b"Item") else { continue };
let a = paragraph(r, attrs);
first_align.get_or_insert(a.align);
@@ -238,7 +249,7 @@ pub(crate) fn story_runs(r: &mut Reader, story: ObjId, world: Affine) -> (Vec<Te
}
};
let mut start = 0usize;
for run in s.obj(block, b"GAtt").map(|g| s.objs(g, b"Runs")).unwrap_or_default() {
for run in attribute_runs {
let Some(index) = s.int(run, b"Indx").and_then(|v| usize::try_from(v).ok()) else {
r.warn("invalid text attribute range (bounded; unassigned characters use defaults)");
continue;
+3 -3
View File
@@ -1227,10 +1227,10 @@ impl<'a> Scene<'a> {
let Some(&first) = run.first() else { continue };
let lo = run.iter().map(|g| g.byte).min().unwrap_or(first.byte);
let hi = run.iter().map(|g| g.byte + g.len).max().unwrap_or(lo);
// A right-to-left paragraph's marker is drawn with its tab first and its number's
// period at the number's left; the TEXT entity holds the characters in that order.
// A right-to-left paragraph's marker is drawn with its number's period at the
// number's left; the TEXT entity holds the characters in that order.
let marker = chunk.first().and_then(|(_, _, m)| *m).map(|m| match m.text.strip_suffix('.') {
Some(number) if m.tab == m.glyphs.start => format!(".{number}"),
Some(number) if layout.glyphs.get(m.tab).is_some_and(|g| g.rtl) => format!(".{number}"),
_ => m.text.clone(),
});
let Some(s) = marker.as_deref().or_else(|| plain.get(lo..hi).map(str::trim_end)).filter(|s| !s.trim().is_empty()) else { continue };
+21 -4
View File
@@ -1076,15 +1076,15 @@ fn a_bullet_is_text_of_its_own_before_its_paragraph() {
/// A numbered list paragraph's number and period are a TEXT entity of their own where they are
/// drawn, in the order they are drawn: in a right-to-left paragraph the period comes first, at the
/// number's left.
/// number's left, with the marker aligned left or right.
#[test]
fn a_number_is_text_of_its_own_before_its_paragraph() {
use vectorcraft_doc::text::{NumberStyle, ParaList};
let numbered = |text: &str, count: u32| {
use vectorcraft_doc::text::{ListAlign, NumberStyle, ParaList};
let numbered_aligned = |text: &str, count: u32, align: ListAlign| {
let mut d = text_doc(text, 20.0);
let id = d.layers[0].children().unwrap()[0].id;
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
t.para.list = Some(ParaList::numbered(NumberStyle::Arabic));
t.para.list = Some(ParaList::numbered(NumberStyle::Arabic).aligned(align));
t.para.left_indent = 30.0;
t.para.first_line_indent = -30.0;
t.list_count = count;
@@ -1092,10 +1092,27 @@ fn a_number_is_text_of_its_own_before_its_paragraph() {
let out = export(&d, &DxfOptions { version: DxfVersion::R2018, preserve: Preserve::Editability, ..opts(&d) }).unwrap();
entities(&out).iter().map(|e| (get(e, 1).map(str::to_string), all(e, 10)[0])).collect::<Vec<_>>()
};
let numbered = |text: &str, count: u32| numbered_aligned(text, count, ListAlign::Left);
assert_eq!(numbered("Item", 11), [(Some("12.".to_string()), 10.0), (Some("Item".to_string()), 40.0)]);
let rtl = numbered("שלום", 11);
assert_eq!(rtl.iter().map(|(s, _)| s.clone().unwrap_or_default()).collect::<Vec<_>>(), ["שלום", ".12"]);
assert!(rtl[1].1 > rtl[0].1, "the number right of the text: {rtl:?}");
// Aligned right, the number keeps its order and moves right, against the text.
let right = numbered_aligned("Item", 11, ListAlign::Right);
assert_eq!((right[0].0.as_deref(), right[1].clone()), (Some("12."), (Some("Item".to_string()), 40.0)));
assert!(right[0].1 > 10.0 && right[0].1 < 40.0, "{right:?}");
let rtl = numbered_aligned("שלום", 11, ListAlign::Right);
assert_eq!(rtl.iter().map(|(s, _)| s.clone().unwrap_or_default()).collect::<Vec<_>>(), ["שלום", ".12"]);
// A mixed paragraph: the period follows the paragraph's direction, not the script of the
// words it holds, with either alignment.
let marker = |text: &str, align: ListAlign| {
let out = numbered_aligned(text, 11, align);
out.into_iter().filter_map(|(s, _)| s).find(|s| s.contains("12")).unwrap_or_default()
};
for align in [ListAlign::Left, ListAlign::Right] {
assert_eq!(marker("Item שלום", align), "12.", "{align:?}");
assert_eq!(marker("שלום Item", align), ".12", "{align:?}");
}
}
/// Area type that Shrink Text to Fit scales down is written at the height it draws at.
+176 -102
View File
@@ -1,19 +1,26 @@
//! "VectorCraft Generic CMYK (SWOP-like)": our own parametric press model.
//!
//! We can't redistribute Adobe's or ECI's characterisation profiles, so the built-in CMYK space is
//! a small physical model instead of an ICC LUT. It is *ours* and only SWOP-*like*: the numbers
//! are rounded, typical values for coated web offset, not a measured dataset.
//! a small physical model instead of an ICC LUT. It is *ours* and only SWOP-*like*. Two of its
//! figures are published values for SWOP printing: the tone value increase of each ink at 40% and
//! 50% from CGATS TR 001 (<https://registry.color.org/cmyk-registry/cgats_tr_001>), and the 300%
//! total ink limit (<https://en.wikipedia.org/wiki/Specifications_for_Web_Offset_Publications>).
//! The paper, solid and overprint colors and the black-generation curve are VectorCraft's own values.
//!
//! * **Forward (CMYK → XYZ):** each ink's nominal dot area is widened by a parabolic dot-gain
//! curve (`a + 4·g·a·(1−a)`, i.e. `g` gain at 50%); the effective areas mix the eight CMY
//! Neugebauer primaries with Demichel weights; black is mixed in the same way against
//! "primary + K" colours whose optical densities add but saturate towards a maximum density
//! (normalised so K on paper is exact), so rich blacks don't multiply to an unrealistic depth.
//! * **Forward (CMYK → XYZ):** each ink's nominal dot area `a` is widened by a tone curve
//! `a + 4·g·a·(1−a)·(1 + s·(2a−1))` that passes through the TR 001 tone value increase at 50%
//! (`g`) and 40% (which sets `s`). The effective areas mix the 16 Neugebauer primaries (paper and
//! every solid and overprint of C, M, Y and K, media-relative) with Demichel weights, linearly in
//! XYZ, so a single ink's colorimetric tone value increase (Murray–Davies) is the TR 001 value.
//! * **Inverse (Lab → CMYK):** damped, bounded Gauss–Newton on the forward model solves CMY for a
//! fixed K. Black generation is GCR: solve CMY with K = 0, take the grey component
//! `min(C, M, Y)`, map it through the black-generation curve to K, re-solve CMY. Under-colour
//! fixed K. Black generation is GCR: solve CMY with K = 0, take the gray component
//! `min(C, M, Y)`, add [`BlackGeneration::shortfall`] for each L* point by which that solve stays
//! lighter than the target, map the sum through the black curve to K, re-solve CMY. Under-color
//! removal enforces the total area coverage (TAC) limit by adding K and re-solving, then scaling
//! CMY as a last resort.
//! CMY as a last resort. The black point, which source black separates to and which displays as
//! black with black-point compensation, is [`BlackGeneration::black_point_k`] black with neutral
//! CMY filling the TAC. With compensation, a build darker than it in every XYZ channel also
//! displays as black (see [`GenericCmyk::display_xyz`]).
//! * **Rendering intents:** colorimetric intents map the source white to paper (relative) or not
//! (absolute) and clip; perceptual applies black-point compensation plus soft chroma compression
//! against a gamut boundary descriptor (segment maxima of the forward model); saturation keeps
@@ -24,50 +31,82 @@ use std::sync::OnceLock;
use super::Intent;
use super::lab::{D50, Lab, delta_e2000, lab_to_xyz, srgb_to_lab, xyz_to_lab, xyz_to_srgb};
/// Black generation strength.
/// Black generation.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct BlackGeneration {
/// Grey component below which no black is generated.
pub start: f32,
/// Maximum black.
pub max_k: f32,
/// Curve exponent (>1 = lighter black in the midtones).
pub gamma: f32,
/// The black curve as `(x, K)` knots with rising `x`, read by linear interpolation. `x` is the
/// gray component of the solve without black plus [`Self::shortfall`] per L* point by which
/// that solve stays lighter than the target.
pub curve: [(f32, f32); 13],
/// Added to the gray component for each L* point the black-free solve stays too light.
pub shortfall: f32,
/// Black of the black point, whose CMY is neutral and fills the TAC.
pub black_point_k: f32,
}
/// Parameters of the parametric press model.
#[derive(Clone, Debug)]
pub struct GenericCmykParams {
pub name: &'static str,
/// Paper color (absolute Lab, D50).
pub paper: Lab,
/// Solid overprints, Demichel order: C, M, CM, Y, CY, MY, CMY.
pub solids: [Lab; 7],
pub black: Lab,
/// Dot gain at 50% per ink (C, M, Y, K).
pub dot_gain: [f32; 4],
/// Media-relative Lab of the solids and overprints, Demichel order (index `c | m<<1 | y<<2 |
/// k<<3`, paper left out): C, M, CM, Y, CY, MY, CMY, then K, CK, MK, CMK, YK, CYK, MYK, CMYK.
pub primaries: [Lab; 15],
/// Tone value increase at 50% per ink (C, M, Y, K).
pub tvi50: [f32; 4],
/// Tone value increase at 40% per ink (C, M, Y, K).
pub tvi40: [f32; 4],
/// Total area coverage limit (sum of inks, 1.0 = 100%).
pub tac: f32,
pub bg: BlackGeneration,
}
/// Rounded, typical SWOP-like values (not measured data).
/// The built-in SWOP-like press: TR 001 tone value increase, a 300% ink limit, and VectorCraft's
/// own paper, primaries and black curve.
pub fn swop_like() -> GenericCmykParams {
GenericCmykParams {
name: super::GENERIC_CMYK,
paper: Lab::new(93.0, -0.5, 2.5),
solids: [
Lab::new(55.0, -37.0, -50.0), // C
Lab::new(48.0, 74.0, -3.0), // M
Lab::new(24.0, 22.0, -46.0), // CM (blue)
Lab::new(89.0, -5.0, 93.0), // Y
Lab::new(50.0, -65.0, 27.0), // CY (green)
Lab::new(47.0, 68.0, 48.0), // MY (red)
Lab::new(24.0, 1.0, 0.0), // CMY
primaries: [
Lab::new(65.7, -42.6, -47.2), // C
Lab::new(55.5, 76.2, -9.4), // M
Lab::new(31.1, 20.3, -49.7), // CM (blue)
Lab::new(96.2, -7.0, 90.1), // Y
Lab::new(61.0, -68.4, 30.1), // CY (green)
Lab::new(55.0, 69.8, 45.0), // MY (red)
Lab::new(28.3, 0.7, -0.5), // CMY
Lab::new(21.5, 0.8, -0.9), // K
Lab::new(15.9, -8.4, -10.0), // CK
Lab::new(14.7, 15.4, -1.0), // MK
Lab::new(11.2, 8.9, -6.6), // CMK
Lab::new(20.2, -2.4, 12.0), // YK
Lab::new(15.7, -14.6, 4.6), // CYK
Lab::new(14.5, 11.2, 5.7), // MYK
Lab::new(11.0, -0.7, 0.1), // CMYK
],
black: Lab::new(19.0, 0.5, 0.5),
dot_gain: [0.20, 0.20, 0.20, 0.22],
tvi50: [0.200, 0.176, 0.168, 0.216],
tvi40: [0.200, 0.173, 0.167, 0.219],
tac: 3.0,
bg: BlackGeneration { start: 0.1, max_k: 0.95, gamma: 1.25 },
bg: BlackGeneration {
curve: [
(0.0, 0.0),
(0.25, 0.0),
(0.35, 0.006),
(0.45, 0.059),
(0.55, 0.139),
(0.65, 0.23),
(0.75, 0.322),
(0.85, 0.41),
(0.95, 0.511),
(1.05, 0.6),
(1.2, 0.733),
(1.35, 0.858),
(1.5, 1.0),
],
shortfall: 0.03,
black_point_k: 0.9,
},
}
}
@@ -83,11 +122,12 @@ const GBD_H: usize = 72; // 5° bins
pub struct GenericCmyk {
pub params: GenericCmykParams,
/// XYZ of the 8 CMY primaries (index = c | m<<1 | y<<2), without and with K.
prim: [[f32; 3]; 8],
prim_k: [[f32; 3]; 8],
/// Media-relative XYZ of the 16 primaries (index = c | m<<1 | y<<2 | k<<3; 0 is paper).
prim: [[f32; 3]; 16],
/// Shape `s` of each ink's tone curve, from its 40% and 50% tone value increase.
skew: [f32; 4],
paper_xyz: [f32; 3],
/// Media-relative XYZ of the darkest printable black (for black-point compensation).
/// Media-relative XYZ of the black point (for black-point compensation).
black_rel: [f32; 3],
gbd: OnceLock<Gbd>,
}
@@ -95,37 +135,17 @@ pub struct GenericCmyk {
impl GenericCmyk {
pub fn new(params: GenericCmykParams) -> Self {
let paper_xyz = lab_to_xyz(params.paper);
let mut prim = [[0.0f32; 3]; 8];
prim[0] = paper_xyz;
for (p, lab) in prim[1..].iter_mut().zip(params.solids.iter()) {
*p = lab_to_xyz(*lab);
let mut prim = [D50; 16];
for (p, lab) in prim[1..].iter_mut().zip(params.primaries.iter()) {
*p = lab_to_xyz(*lab).map(|v| v.max(1e-6));
}
let k_xyz = lab_to_xyz(params.black);
// Black over other inks: optical densities add, but saturate towards a maximum density
// (ink trapping / surface reflection), normalised so that K on paper is exact.
const D_MAX: f32 = 2.0;
let sat = |d: f32| D_MAX * (1.0 - (-d / D_MAX).exp());
let mut prim_k = [[0.0f32; 3]; 8];
for i in 0..8 {
for ch in 0..3 {
let di = -(prim[i][ch] / paper_xyz[ch]).max(1e-6).log10();
let dk = -(k_xyz[ch] / paper_xyz[ch]).max(1e-6).log10();
let d = sat(di + dk) * dk / sat(dk).max(1e-6);
prim_k[i][ch] = paper_xyz[ch] * 10f32.powf(-d.max(di));
}
}
let mut g = Self { params, prim, prim_k, paper_xyz, black_rel: [0.0; 3], gbd: OnceLock::new() };
// Darkest black within TAC: search a few rich blacks.
let mut best = [1.0f32; 3];
for k in [0.8f32, 0.9, 1.0] {
let rest = (g.params.tac - k).max(0.0) / 3.0;
let cmy = rest.min(1.0);
let x = g.to_xyz_rel([cmy, cmy, cmy, k]);
if x[1] < best[1] {
best = x;
}
}
g.black_rel = best;
// The curve's increase at 40% is 0.96·g·(1 − 0.2·s), so `s` follows from the 40% value.
let skew = [0, 1, 2, 3].map(|i| {
let g = params.tvi50[i];
if g > 1e-6 { (1.0 - params.tvi40[i] / (0.96 * g)) / 0.2 } else { 0.0 }
});
let mut g = Self { params, prim, skew, paper_xyz, black_rel: [0.0; 3], gbd: OnceLock::new() };
g.black_rel = g.to_xyz_rel(g.black_point_build());
g
}
@@ -133,35 +153,81 @@ impl GenericCmyk {
self.params.name
}
fn gain(a: f32, g: f32) -> f32 {
let a = a.clamp(0.0, 1.0);
(a + 4.0 * g * a * (1.0 - a)).clamp(0.0, 1.0)
/// The black point: [`BlackGeneration::black_point_k`] black, with C, M and Y
/// chosen so that they fill the TAC and the color is neutral (a* = b* = 0).
pub fn black_point_build(&self) -> [f32; 4] {
let k = self.params.bg.black_point_k.clamp(0.0, 1.0);
let rest = (self.params.tac - k).clamp(0.0, 3.0);
let build = |c: f32, m: f32| [c, m, (rest - c - m).clamp(0.0, 1.0), k];
let ab = |c: f32, m: f32| {
let l = self.to_lab_rel(build(c, m));
[l.a, l.b]
};
let lo = (rest - 2.0).max(0.0);
let hi = rest.min(1.0);
let (mut c, mut m) = ((rest / 3.0).clamp(lo, hi), (rest / 3.0).clamp(lo, hi));
for _ in 0..30 {
let r = ab(c, m);
if r[0].abs() + r[1].abs() < 1e-4 {
break;
}
let h = 1e-3;
let rc = ab(c - h, m);
let rm = ab(c, m - h);
let j = [[(r[0] - rc[0]) / h, (r[0] - rm[0]) / h], [(r[1] - rc[1]) / h, (r[1] - rm[1]) / h]];
let det = j[0][0] * j[1][1] - j[0][1] * j[1][0];
if det.abs() < 1e-9 {
break;
}
let dc = (-r[0] * j[1][1] + r[1] * j[0][1]) / det;
let dm = (-r[1] * j[0][0] + r[0] * j[1][0]) / det;
c = (c + dc.clamp(-0.1, 0.1)).clamp(lo, hi);
m = (m + dm.clamp(-0.1, 0.1)).clamp(lo, hi);
// Yellow takes the rest, so keep it within 0..1 as well.
let y = rest - c - m;
if y > 1.0 {
c += (y - 1.0) / 2.0;
m += (y - 1.0) / 2.0;
} else if y < 0.0 {
c += y / 2.0;
m += y / 2.0;
}
c = c.clamp(lo, hi);
m = m.clamp(lo, hi);
}
build(c.clamp(lo, hi), m.clamp(lo, hi))
}
/// Effective dot area of ink `i` at nominal area `a`: the TR 001-style tone curve.
fn gain(&self, i: usize, a: f32) -> f32 {
let a = if a.is_finite() { a.clamp(0.0, 1.0) } else { 0.0 };
let g = self.params.tvi50[i];
(a + 4.0 * g * a * (1.0 - a) * (1.0 + self.skew[i] * (2.0 * a - 1.0))).clamp(0.0, 1.0)
}
/// CMYK (0..1) → absolute XYZ (D50).
pub fn to_xyz(&self, cmyk: [f32; 4]) -> [f32; 3] {
let dg = self.params.dot_gain;
let c = Self::gain(cmyk[0], dg[0]);
let m = Self::gain(cmyk[1], dg[1]);
let y = Self::gain(cmyk[2], dg[2]);
let k = Self::gain(cmyk[3], dg[3]);
let mut out = [0.0f32; 3];
for i in 0..8 {
let w = (if i & 1 != 0 { c } else { 1.0 - c }) * (if i & 2 != 0 { m } else { 1.0 - m }) * (if i & 4 != 0 { y } else { 1.0 - y });
if w == 0.0 {
continue;
}
for (ch, o) in out.iter_mut().enumerate() {
*o += w * ((1.0 - k) * self.prim[i][ch] + k * self.prim_k[i][ch]);
}
}
out
let x = self.to_xyz_rel(cmyk);
[x[0] * self.paper_xyz[0] / D50[0], x[1] * self.paper_xyz[1] / D50[1], x[2] * self.paper_xyz[2] / D50[2]]
}
/// Media-relative XYZ (paper = D50 white).
pub fn to_xyz_rel(&self, cmyk: [f32; 4]) -> [f32; 3] {
let x = self.to_xyz(cmyk);
[x[0] * D50[0] / self.paper_xyz[0], x[1] * D50[1] / self.paper_xyz[1], x[2] * D50[2] / self.paper_xyz[2]]
let e = [0, 1, 2, 3].map(|i| self.gain(i, cmyk[i]));
let mut out = [0.0f32; 3];
for (i, p) in self.prim.iter().enumerate() {
let mut w = 1.0;
for (bit, a) in e.iter().enumerate() {
w *= if i & (1 << bit) != 0 { *a } else { 1.0 - a };
}
if w == 0.0 {
continue;
}
for (o, v) in out.iter_mut().zip(p) {
*o += w * v;
}
}
out
}
pub fn to_lab_rel(&self, cmyk: [f32; 4]) -> Lab {
@@ -174,7 +240,9 @@ impl GenericCmyk {
/// Display XYZ: media-relative (paper shows as white) or absolute (simulate paper colour).
/// With `bpc` (media-relative only) the press black displays as black, undoing the
/// compensation [`Self::gamut_map`] applies on the way in, so sRGB black separated to the
/// darkest rich black shows as black again.
/// black point shows as black again. Each XYZ channel is clamped at 0 on its own, so a build
/// darker than the black point in every channel shows as black, and one darker in only some
/// channels shows as a colored near-black.
pub fn display_xyz(&self, cmyk: [f32; 4], absolute: bool, bpc: bool) -> [f32; 3] {
if absolute {
return self.to_xyz(cmyk);
@@ -192,7 +260,7 @@ impl GenericCmyk {
xyz_to_srgb(self.display_xyz(cmyk, absolute, bpc))
}
/// Lab of the darkest printable black (media-relative).
/// Lab of the black point (media-relative), see [`Self::black_point_build`].
pub fn black_point(&self) -> Lab {
xyz_to_lab(self.black_rel)
}
@@ -283,12 +351,21 @@ impl GenericCmyk {
rgb.map(|v| ((1.0 - v - k) / d).clamp(0.0, 1.0))
}
fn black_for_grey(&self, grey: f32) -> f32 {
let bg = self.params.bg;
if grey <= bg.start {
return 0.0;
/// Black for `x` on the black curve (see [`BlackGeneration::curve`]).
fn black_for(&self, x: f32) -> f32 {
let curve = &self.params.bg.curve;
let mut prev = curve[0];
if x <= prev.0 {
return prev.1.clamp(0.0, 1.0);
}
(((grey - bg.start) / (1.0 - bg.start)).clamp(0.0, 1.0).powf(bg.gamma) * bg.max_k).clamp(0.0, 1.0)
for &(kx, kk) in &curve[1..] {
if x <= kx {
let t = if kx > prev.0 { (x - prev.0) / (kx - prev.0) } else { 1.0 };
return (prev.1 + (kk - prev.1) * t).clamp(0.0, 1.0);
}
prev = (kx, kk);
}
prev.1.clamp(0.0, 1.0)
}
/// Separate a media-relative Lab (already gamut-mapped) into CMYK with GCR/UCR and the TAC limit.
@@ -297,13 +374,10 @@ impl GenericCmyk {
return [0.0; 4];
}
let cmy0 = self.solve_cmy(target, 0.0, Self::initial_cmy(target, 0.0));
let grey = cmy0[0].min(cmy0[1]).min(cmy0[2]);
let mut k = self.black_for_grey(grey);
// If CMY alone can't get dark enough, black must carry the rest.
let gray = cmy0[0].min(cmy0[1]).min(cmy0[2]);
// When CMY alone can't get dark enough, black carries the rest.
let l0 = self.to_lab_rel([cmy0[0], cmy0[1], cmy0[2], 0.0]).l;
if l0 > target.l + 1.0 {
k = k.max(((l0 - target.l) / l0.max(1.0)).clamp(0.0, 1.0));
}
let mut k = self.black_for(gray + self.params.bg.shortfall * (l0 - target.l).max(0.0));
let mut cmy = if k > 0.0 { self.solve_cmy(target, k, Self::initial_cmy(target, k)) } else { cmy0 };
// UCR: trade CMY for K until within the TAC limit.
for _ in 0..6 {
+94 -15
View File
@@ -245,8 +245,8 @@ fn loads_system_cmyk_icc() {
assert_eq!(c.cmyk_to_srgb([0.0; 4], false).map(|v| (v * 255.0).round() as u8), [255, 255, 255]);
}
/// With black-point compensation, sRGB black separates to the press's darkest black and that black
/// displays as black again (hex 000000 -> C67 M67 Y67 K100 -> 000000, not a dark grey).
/// With black-point compensation, sRGB black separates to the press's black point and that black
/// displays as black again (hex 000000 -> C75 M68 Y67 K90 -> 000000, not a dark gray).
#[test]
fn generic_cmyk_black_round_trips_with_bpc() {
let c = cms();
@@ -259,25 +259,104 @@ fn generic_cmyk_black_round_trips_with_bpc() {
assert!(raw.iter().all(|v| *v > 0.05), "{raw:?}");
}
/// A color whose solve holds cyan at 0 separates to the same inks when its values change by a few
/// float steps: hex ed1e79 with its red stepped one `f32` at a time over 4,001 values (about 0.00024
/// in all) stays within 0.001 on every ink.
/// Each ink's 40% and 50% tints gain like CGATS TR 001 (SWOP): the colorimetric tone value increase
/// (Murray–Davies on X for cyan, Y for magenta and black, Z for yellow) is C 20.0, M 17.6, Y 16.8 and
/// K 21.6 points at 50%, and C 20.0, M 17.3, Y 16.7 and K 21.9 points at 40%.
#[test]
fn generic_cmyk_tints_gain_like_tr001() {
let g = generic();
let tvi = |ink: usize, a: f32| {
let ch = [0, 1, 2, 1][ink];
let mut cmyk = [0.0f32; 4];
cmyk[ink] = a;
let tint = g.to_xyz_rel(cmyk)[ch];
cmyk[ink] = 1.0;
let solid = g.to_xyz_rel(cmyk)[ch];
let paper = g.to_xyz_rel([0.0; 4])[ch];
(paper - tint) / (paper - solid) - a
};
for (ink, (at50, at40)) in [(0.200, 0.200), (0.176, 0.173), (0.168, 0.167), (0.216, 0.219)].into_iter().enumerate() {
assert!((tvi(ink, 0.5) - at50).abs() < 0.002, "ink {ink} at 50%: {}", tvi(ink, 0.5));
assert!((tvi(ink, 0.4) - at40).abs() < 0.002, "ink {ink} at 40%: {}", tvi(ink, 0.4));
}
}
/// More of any ink never makes a Generic CMYK color lighter, in any XYZ channel: on a 10% grid of
/// builds within the 300% ink limit, adding 10% of one ink keeps X, Y and Z or lowers them.
#[test]
fn generic_cmyk_more_ink_is_never_lighter() {
let g = generic();
let steps = (0..=10).map(|i| i as f32 / 10.0);
for c in steps.clone() {
for m in steps.clone() {
for y in steps.clone() {
for k in steps.clone() {
let base = [c, m, y, k];
if base.iter().sum::<f32>() > 3.0 - 0.1 + 1e-4 {
continue;
}
let x0 = g.to_xyz_rel(base);
for ink in 0..4 {
if base[ink] >= 1.0 {
continue;
}
let mut more = base;
more[ink] += 0.1;
let x1 = g.to_xyz_rel(more);
assert!((0..3).all(|ch| x1[ch] <= x0[ch] + 1e-5), "{base:?} plus ink {ink}: {x0:?} to {x1:?}");
}
}
}
}
}
}
/// sRGB black separates to 90% black under neutral CMY that fills the 300% ink limit, light grays
/// carry no black, and black rises steadily from the light end of the gray ramp to the dark end.
#[test]
fn generic_cmyk_black_generation_follows_the_black_curve() {
let c = cms();
let black = c.srgb_to_cmyk([0.0; 3], Intent::RelativeColorimetric);
assert!((black[3] - 0.9).abs() < 0.005, "{black:?}");
assert!((black.iter().sum::<f32>() - 3.0).abs() < 0.01, "{black:?}");
let lab = c.cmyk_to_lab(black);
assert!(lab.a.abs() < 0.05 && lab.b.abs() < 0.05, "neutral: {lab:?}");
let light = c.srgb_to_cmyk([0.7; 3], Intent::RelativeColorimetric);
assert_eq!(light[3], 0.0, "{light:?}");
let mut prev = 0.0;
for v in (0..=50).rev().map(|i| i as f32 / 50.0) {
let k = c.srgb_to_cmyk([v; 3], Intent::RelativeColorimetric)[3];
assert!(k >= prev - 1e-4, "gray {v}: K {k} after {prev}");
prev = k;
}
}
/// A color whose solve holds an ink at a bound separates to the same inks when its values change by
/// a few float steps: hex f01482 (cyan held at 0) and hex ed1e79 (magenta held at 100%), each with
/// its red stepped one `f32` at a time over 4,001 values (about 0.00024 in all), stay within 0.001
/// on every ink.
#[test]
fn generic_cmyk_separates_nearby_colors_at_the_gamut_edge_alike() {
let c = cms();
let pink = [237.0f32 / 255.0, 30.0 / 255.0, 121.0 / 255.0];
let (mut lo, mut hi) = ([f32::MAX; 4], [f32::MIN; 4]);
for step in -2000i32..=2000 {
let mut rgb = pink;
rgb[0] = f32::from_bits(pink[0].to_bits().wrapping_add_signed(step));
let ink = c.srgb_to_cmyk(rgb, Intent::RelativeColorimetric);
for i in 0..4 {
lo[i] = lo[i].min(ink[i]);
hi[i] = hi[i].max(ink[i]);
let spread = |pink: [f32; 3]| {
let (mut lo, mut hi) = ([f32::MAX; 4], [f32::MIN; 4]);
for step in -2000i32..=2000 {
let mut rgb = pink;
rgb[0] = f32::from_bits(pink[0].to_bits().wrapping_add_signed(step));
let ink = c.srgb_to_cmyk(rgb, Intent::RelativeColorimetric);
for i in 0..4 {
lo[i] = lo[i].min(ink[i]);
hi[i] = hi[i].max(ink[i]);
}
}
}
(lo, hi)
};
let (lo, hi) = spread([240.0f32 / 255.0, 20.0 / 255.0, 130.0 / 255.0]);
assert!((0..4).all(|i| hi[i] - lo[i] < 1e-3), "{lo:?} to {hi:?}");
assert_eq!(lo[0], 0.0, "{lo:?}");
let (lo, hi) = spread([237.0f32 / 255.0, 30.0 / 255.0, 121.0 / 255.0]);
assert!((0..4).all(|i| hi[i] - lo[i] < 1e-3), "{lo:?} to {hi:?}");
assert_eq!(hi[1], 1.0, "{hi:?}");
}
/// A color whose solve holds cyan and magenta at 1 separates to the same inks when its values change
+5
View File
@@ -376,6 +376,11 @@ impl Appearance {
pub fn has_placed_gradient(&self) -> bool {
self.has_gradient(true)
}
/// Does a fill or stroke carry a gradient that is drawn at a place in document space: a placed
/// one, or a freeform gradient with points of its own whose vector is fitted to the bounds?
pub fn has_positioned_gradient(&self) -> bool {
self.gradients().any(|g| g.is_placed() || g.has_freeform_points())
}
/// Fix unplaced gradients to their fit on `bounds` (the object's geometric bounds; strokes fit
/// the stroke-inflated box), so they can follow transforms that refitting wouldn't reproduce.
pub fn pin_gradients(&mut self, bounds: vectorcraft_geom::Rect) {
+30 -1
View File
@@ -34,7 +34,16 @@ impl Document {
/// The name of the next unnamed asset: one more than the highest `Asset N` so far.
pub fn next_asset_name(&self) -> String {
let n = self.assets.iter().filter_map(|a| a.name.strip_prefix(ASSET)?.parse::<u64>().ok()).max().unwrap_or(0);
let n = self
.assets
.iter()
.filter_map(|a| {
let digits = a.name.strip_prefix(ASSET)?;
// `parse` alone would accept a leading `+`.
digits.bytes().all(|b| b.is_ascii_digit()).then(|| digits.parse::<u64>().ok()).flatten()
})
.max()
.unwrap_or(0);
format!("{ASSET}{}", n.saturating_add(1))
}
@@ -61,3 +70,23 @@ impl Document {
v.into_iter().map(|(_, id)| id).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn name_after(names: &[&str]) -> String {
let mut doc = Document::new(100.0, 100.0);
for (i, n) in names.iter().enumerate() {
doc.assets.push(ExportAsset { id: i as u64 + 1, name: (*n).to_string(), nodes: vec![], format_3d: None });
}
doc.next_asset_name()
}
#[test]
fn next_asset_name_ignores_non_digit_suffixes() {
assert_eq!(name_after(&["Asset +5"]), "Asset 1");
assert_eq!(name_after(&["Asset ", "Asset 5x"]), "Asset 1");
assert_eq!(name_after(&["Asset 5"]), "Asset 6");
}
}
+69 -14
View File
@@ -19,7 +19,7 @@ use std::f64::consts::PI;
use std::sync::Arc;
use vectorcraft_color::{Color, Gradient, GradientPaint, GradientStop, Paint};
use vectorcraft_geom::kurbo::ParamCurveArclen;
use vectorcraft_geom::kurbo::{ParamCurve, ParamCurveArclen, ParamCurveDeriv};
use vectorcraft_geom::{Affine, Anchor, BezPath, FillRule, PathData, Point, Rect, SubPath, Vec2};
use crate::appearance::{Appearance, AppearanceItem, Dash, StrokeLayer, WidthProfile};
@@ -904,9 +904,12 @@ pub struct Spine {
cum: Vec<f64>,
anchors: Vec<f64>,
closed: bool,
path: PathData,
}
impl Spine {
/// Build a spine from the first subpath of `path`: flatten it to a polyline, record cumulative
/// arc lengths, and the arc length at each anchor.
pub fn new(path: &PathData) -> Option<Self> {
let sp = path.subpaths.first()?;
let mut pts: Vec<Point> = Vec::new();
@@ -943,8 +946,9 @@ impl Spine {
if pts.len() < 2 {
return None;
}
Some(Self { pts, cum, anchors, closed: sp.closed })
Some(Self { pts, cum, anchors, closed: sp.closed, path: path.clone() })
}
/// The spine's flattened length in arc length.
pub fn length(&self) -> f64 {
*self.cum.last().unwrap_or(&0.0)
}
@@ -1002,20 +1006,32 @@ impl Spine {
pub fn at(&self, f: f64) -> (Point, f64) {
self.at_length(f.clamp(0.0, 1.0) * self.length())
}
/// Point and tangent angle (radians) at arc length `s` (clamped to the spine).
/// Point and tangent angle (radians) at arc length `s` (clamped to the spine). The point and
/// tangent come from the original curve at that arc length, not from the flattened polyline: a
/// flattened chord's direction is piecewise constant, so steps oriented to a curved spine with
/// `Align to Path` jump between chords instead of following the curve smoothly.
pub fn at_length(&self, s: f64) -> (Point, f64) {
let last = self.pts.len().saturating_sub(2);
let s = if s.is_finite() { s.clamp(0.0, self.length()) } else { 0.0 };
let i = match self.cum.binary_search_by(|c| c.total_cmp(&s)) {
Ok(i) => i.min(last),
Err(i) => i.saturating_sub(1).min(last),
};
let (Some(p0), Some(p1), Some(c0), Some(c1)) = (self.pts.get(i), self.pts.get(i + 1), self.cum.get(i), self.cum.get(i + 1)) else {
return (self.pts.first().copied().unwrap_or_default(), 0.0);
};
let u = ((s - c0) / (c1 - c0).max(1e-12)).clamp(0.0, 1.0);
let d = *p1 - *p0;
(p0.lerp(*p1, u), d.y.atan2(d.x))
let Some(sp) = self.path.subpaths.first() else { return (self.pts.first().copied().unwrap_or_default(), 0.0) };
let n = sp.segment_count();
let mut start = 0.0;
for i in 0..n {
let c = sp.segment(i);
let end = self.anchor_length(i + 1).unwrap_or(self.length());
if s <= end || i + 1 == n {
let local_s = (s - start).max(0.0) / (end - start).max(1e-12) * c.arclen(1e-9);
let t = c.inv_arclen(local_s, 1e-9).clamp(0.0, 1.0);
let e = c.eval(t);
let tv = c.deriv().eval(t).to_vec2();
// Where a handle sits on its anchor the derivative vanishes there: take the
// direction between points a little before and after instead.
let tv = if tv.hypot2() < 1e-24 { c.eval((t + 1e-3).min(1.0)) - c.eval((t - 1e-3).max(0.0)) } else { tv };
return (Point::new(e.x, e.y), tv.y.atan2(tv.x));
}
start = end;
}
// Only a spine without segments gets here: the loop returns at the last one.
(self.pts.first().copied().unwrap_or_default(), 0.0)
}
}
@@ -1446,6 +1462,45 @@ mod tests {
assert_eq!(out[0].geometric_bounds().unwrap().center(), Point::new(5.0, 5.0), "keys stay put");
}
#[test]
fn orientations_follow_a_curved_spine_smoothly() {
// A curved spine: from (0, 0) with handles (+50, +50) / (-50, -50) to (100, 0) with handles
// (+50, -50) / (-50, +50) — the classic up-bow arc.
let spine = PathData::single(SubPath::new(
vec![Anchor::smooth(Point::new(0.0, 0.0), Point::new(50.0, 50.0)), Anchor::smooth(Point::new(100.0, 0.0), Point::new(50.0, -50.0))],
false,
));
let sp = Spine::new(&spine).unwrap();
// Many steps along the bow: each step's tangent comes from the curve at its arc length,
// not from a flattened chord — so the orientations must sweep the true angle, smoothly.
let len = sp.length();
let mut prev: Option<f64> = None;
for i in 1..=200 {
let (pt, ang) = sp.at_length(len * i as f64 / 201.0);
if let Some(p) = prev {
let step = (ang - p).abs().min(2.0 * std::f64::consts::PI - (ang - p).abs());
assert!(step < 0.1, "tangent step {} is too big at {} ({}, {})", step, i, pt.x, pt.y);
}
prev = Some(ang);
}
}
/// Where the spine starts at an anchor whose handle sits on it, the first step still faces along
/// the curve (towards the next handle), not along a direction of zero length.
#[test]
fn a_handle_on_its_anchor_still_gives_the_curve_direction() {
let spine = PathData::single(SubPath::new(
vec![Anchor::corner(Point::new(0.0, 0.0)), Anchor::smooth(Point::new(100.0, 0.0), Point::new(150.0, -50.0))],
false,
));
let sp = Spine::new(&spine).unwrap();
let (p, angle) = sp.at_length(0.0);
assert!(p.x.abs() < 1e-6 && p.y.abs() < 1e-6, "{p:?}");
assert!((angle - std::f64::consts::FRAC_PI_4).abs() < 0.05, "faces the in handle at (50, 50): {angle}");
let (_, end) = sp.at_length(sp.length());
assert!(end.is_finite(), "{end}");
}
#[test]
fn easing_keeps_the_ends_and_stays_monotone() {
use crate::live::{BlendEase, BlendEaseType as K};
+3
View File
@@ -34,6 +34,7 @@ impl Document {
graphic_styles,
char_styles,
para_styles,
composite_fonts,
text_threads,
symbols,
graph_designs,
@@ -90,6 +91,7 @@ impl Document {
&& *graphic_styles == other.graphic_styles
&& *char_styles == other.char_styles
&& *para_styles == other.para_styles
&& *composite_fonts == other.composite_fonts
&& *text_threads == other.text_threads
&& symbols.len() == other.symbols.len()
&& symbols.iter().zip(&other.symbols).all(|(a, b)| a.name == b.name && Arc::ptr_eq(&a.art, &b.art))
@@ -213,6 +215,7 @@ impl Document {
graphic_styles: default_graphic_styles().into(),
char_styles: Default::default(),
para_styles: Default::default(),
composite_fonts: Default::default(),
text_threads: vec![],
symbols: vec![],
graph_designs: vec![],
+4
View File
@@ -121,6 +121,7 @@ pub use text::{
RubySpan, ScriptMetrics, SingleWordJustify, TabAlign, TabStop, TextFlow, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, VerticalAlign,
WrapShape,
};
pub use text::{CharClass, CompositeFont, CompositePart, CompositeParts};
pub use trace::TraceView;
pub use variables::{DataSet, DataValue, Variable, VariableKind, Variables};
pub use vectorcraft_color as color;
@@ -195,6 +196,9 @@ pub struct Document {
/// Paragraph styles (besides the built-in [Normal Paragraph Style]).
#[serde(default, skip_serializing_if = "skip::is_empty_vec")]
pub para_styles: Shared<Vec<TextStyleDef>>,
/// Type › Composite Fonts…: the composite fonts defined in the document, by name.
#[serde(default, skip_serializing_if = "skip::is_empty_vec")]
pub composite_fonts: Shared<Vec<CompositeFont>>,
/// Threaded text: area-type frames one story flows through, in order.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub text_threads: Vec<Vec<NodeId>>,
+5
View File
@@ -103,6 +103,11 @@ impl Document {
let styles = |s: &Vec<TextStyleDef>| vec_bytes(s) + s.iter().map(|s| s.name.capacity() + map_bytes(&s.attrs)).sum::<usize>();
n += shared(&self.char_styles, &newer.char_styles, styles);
n += shared(&self.para_styles, &newer.para_styles, styles);
n += shared(&self.composite_fonts, &newer.composite_fonts, |c| {
let part = |p: &crate::CompositePart| p.font.capacity() + p.style.capacity();
vec_bytes(c)
+ c.iter().map(|f| f.name.capacity() + crate::CharClass::ALL.into_iter().map(|k| part(f.parts.get(k))).sum::<usize>()).sum::<usize>()
});
n += shared(&self.saved_selections, &newer.saved_selections, |s| {
vec_bytes(s) + s.iter().map(|s: &SavedSelection| s.name.capacity() + vec_bytes(&s.objects)).sum::<usize>()
});
+2 -1
View File
@@ -572,7 +572,8 @@ mod tests {
place(&mut g, &symbols);
g.transform(Affine::new([2.0, 0.0, 0.0, 1.0, 0.0, 0.0]), false);
let rec = g.extras().objects_on_path.as_deref().cloned().unwrap();
assert_eq!((rec.spacing, rec.start), (Some(40.0), 20.0));
// The spine measures arc length along the curve, so within rounding of the exact values.
assert!(rec.spacing.is_some_and(|d| (d - 40.0).abs() < 1e-9) && (rec.start - 20.0).abs() < 1e-9, "{rec:?}");
place(&mut g, &symbols);
let x = |id| g.children().and_then(|c| c.iter().find(|c| c.id == id).cloned()).unwrap().geometric_bounds().unwrap().center().x;
assert!((x(NodeId(2)) - 20.0).abs() < 1e-9 && (x(NodeId(3)) - 60.0).abs() < 1e-9, "{} {}", x(NodeId(2)), x(NodeId(3)));
+21
View File
@@ -120,6 +120,21 @@ impl Quotes {
prev.is_none_or(|p| p.is_whitespace() || matches!(p, '(' | '[' | '{' | '—' | '–') || quote_opened.unwrap_or_else(|| self.is_open(p)))
}
/// The straight quote a typed curly quote stands for: `"` for `”` (U+201D) and nothing else.
/// The macOS Serbian Cyrillic layout has no straight `"` and types `”` on Shift+2, so with
/// Typographer's Quotes on, a `”` typed alone where a quote opens takes the document's opening
/// quote ([`Quotes::opens_after`]); where a quote closes it stays `”`. `“ „` and the single
/// quotes `‘ ’ ‚` stay as typed: that layout's apostrophe key already types `’`, as Serbian
/// wants, and `’90s` keeps its apostrophe.
pub fn typed_as_straight(c: char) -> Option<char> {
(c == '”').then_some('"')
}
/// Does a quote typed after `prev` (the character before it, `None` at the start) open?
pub fn opens_after(&self, prev: Option<char>) -> bool {
self.opens(prev, None)
}
/// `text` with straight quotes (`"` and `'`) replaced by these quotes: opening or closing by
/// context (an apostrophe is a closing single quote). `prev` is the character before `text`,
/// and is updated to its last character, so text can be converted in pieces.
@@ -420,6 +435,12 @@ mod tests {
}
let mut prev = None;
assert_eq!(language_quotes("croatian").unwrap().apply("\"a 'b' c\"", &mut prev), "„a ‚b‘ c“");
// Only a typed `”` stands for a straight quote; the other curly quotes stay as typed.
assert_eq!(Quotes::typed_as_straight('”'), Some('"'));
assert!(['“', '„', '‘', '’', '‚', '"', '\'', '«', '»', '「', 'a'].iter().all(|&c| Quotes::typed_as_straight(c).is_none()));
// A quote opens at the start and after a space, and closes after a letter.
assert!(Quotes::ENGLISH.opens_after(None) && Quotes::ENGLISH.opens_after(Some(' ')));
assert!(!Quotes::ENGLISH.opens_after(Some('a')));
// Bulgarian: „…“ at either level, as CLDR has it.
let mut prev = None;
assert_eq!(language_quotes("bulgarian").unwrap().apply("\"a 'b' c\"", &mut prev), "„a „b“ c“");
+14 -1
View File
@@ -8,6 +8,7 @@ use crate::appearance::{Appearance, AppearanceItem, Dash, FillLayer, LineCap, Li
mod area;
mod char_style;
mod composite;
mod object;
mod para_style;
mod runs;
@@ -17,12 +18,19 @@ mod wrap;
pub use area::{AreaFit, AreaOptions, PathAlign, PathEffect, TextFlow, TextWrap, VerticalAlign, WrapShape};
use char_style::yes;
pub use char_style::{CharAlign, CharPosition, CharStyle, Digits, FontHeightReference, GlyphChoice, ScriptMetrics};
pub use composite::{
COMPOSITE_BASELINE, COMPOSITE_FONTS_MAX, COMPOSITE_NAME_MAX, COMPOSITE_SCALE, COMPOSITE_SIZE, CharClass, CompositeFont, CompositePart,
CompositeParts,
};
pub use para_style::{
Burasagari, Composer, FirstBaseline, HyphenationOptions, JustificationOptions, Justify, Kashida, Kinsoku, LeadingModel, Mojikumi, ParaDirection,
ParaStyle, SingleWordJustify,
};
pub use runs::{INLINE_CHAR, INLINE_MAX_SCALE, InlineArt, RubyAlign, RubyPosition, RubySpan, TextKind, TextRun, TextStyleDef};
pub use tabs_lists::{Bullet, DEFAULT_TAB_INTERVAL, LIST_HANGING_INDENT, NumberStyle, ParaList, TabAlign, TabStop};
pub use tabs_lists::{
Bullet, DEFAULT_TAB_INTERVAL, LIST_HANGING_INDENT, LIST_LEVEL_INDENT, LIST_LEVELS, ListAlign, ListCounts, NumberStyle, ParaList, TabAlign,
TabStop,
};
pub use wrap::{resolve_wrap, wrap_work};
/// A text object. `runs` split into paragraphs at `\n`.
@@ -96,6 +104,11 @@ pub struct TextObject {
/// file saves the count only when it is not 0.
#[serde(default, rename = "listCount", skip_serializing_if = "crate::skip::is_default")]
pub list_count: u32,
/// Threaded text: as [`Self::list_count`], the counts the earlier frames carry into this frame
/// at list levels 2 on, level 2 first ([`ListCounts::from_parts`]). Empty when they are all 0,
/// and the file saves them only when not empty. A file's longer list opens with the first 8.
#[serde(default, rename = "listLevelCounts", skip_serializing_if = "Vec::is_empty", deserialize_with = "tabs_lists::read_level_counts")]
pub list_level_counts: Vec<u32>,
/// Cached layout bounds in text space, filled in by the layout engine (not serialized).
#[serde(skip)]
pub cached_bounds: Option<Rect>,
+13 -4
View File
@@ -101,15 +101,24 @@ impl PathEffect {
}
}
/// Type on a Path Options › Align to Path: which height of the type runs along the path.
/// Type on a Path Options › Align to Path: which height of the type runs along the path. Each
/// character is measured by its own font, size and vertical scale, and in vertical type by its own
/// size and horizontal scale. An inline graphic moves as a character of its run would, by its
/// run's vertical scale in vertical type too. These distances are VectorCraft's own values.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PathAlign {
/// The font's top edge: the type hangs below the path.
/// The top of the font's em box: the type hangs below the path. The em box is the font's
/// ideographic em box (from its `BASE` table, its typographic or `hhea` box, or its
/// ideographs), else an em box centered on the cap height, else a box whose top is 0.88 em
/// above the baseline. In vertical type the characters move half an em.
Ascender,
/// The font's bottom edge: the type stands above the path.
/// The bottom of the font's bounding box, else the font's descent: the type stands above the
/// path. In vertical type the characters move half an em the other way.
Descender,
/// Halfway between the ascender and the descender.
/// A line at half the height of the top of the font's H above the baseline, else at half its
/// cap height, else at the middle of its em box. In vertical type the characters stay on the
/// path.
Center,
/// The baseline (the default).
#[default]
+344
View File
@@ -0,0 +1,344 @@
//! Type › Composite Fonts…: a font made of other fonts, one for each class of character (the
//! Kanji font is the base; kana, punctuation, symbols, Roman letters and numerals each take a font
//! of their own, sized, raised and scaled against it). Type set in a composite font names it as its
//! family; the layout sets each character in its class's font.
//!
//! Behavior reference (public documentation):
//! <https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=347>.
//! Which characters fall in which class, the ranges and the defaults are VectorCraft's own.
use serde::{Deserialize, Serialize};
/// A class of characters a composite font gives a font of its own.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum CharClass {
/// The base font: ideographs, full-width letters and digits, and every character no other
/// class takes.
Kanji,
/// Hiragana and katakana, full-width and half-width.
Kana,
/// East Asian and full-width punctuation, and typographic quotes and dashes.
Punctuation,
/// Arrows, math, shapes, enclosed and other symbols.
Symbols,
/// Half-width Roman letters, their punctuation and spaces.
Roman,
/// The half-width digits 0 to 9.
Numerals,
}
impl CharClass {
/// Every class, in the order the Composite Fonts dialog lists them.
pub const ALL: [CharClass; 6] = [Self::Kanji, Self::Kana, Self::Punctuation, Self::Symbols, Self::Roman, Self::Numerals];
/// The name commands and files use (`kanji`, `kana`, …).
pub fn key(self) -> &'static str {
match self {
Self::Kanji => "kanji",
Self::Kana => "kana",
Self::Punctuation => "punctuation",
Self::Symbols => "symbols",
Self::Roman => "roman",
Self::Numerals => "numerals",
}
}
/// The class named `key` ([`Self::key`], any case).
pub fn named(key: &str) -> Option<Self> {
Self::ALL.into_iter().find(|c| c.key().eq_ignore_ascii_case(key))
}
/// Whether the class's characters can be scaled across and up (Kana, Roman and Numerals).
pub fn scales(self) -> bool {
matches!(self, Self::Kana | Self::Roman | Self::Numerals)
}
/// The class of character `c`.
pub fn of(c: char) -> Self {
let u = c as u32;
match u {
0x30..=0x39 => Self::Numerals,
// Basic Latin, Latin-1, Latin Extended, IPA, spacing and combining marks, and Latin
// Extended Additional.
0x00..=0x036F | 0x1E00..=0x1EFF => Self::Roman,
// The other Latin blocks: combining marks extended, phonetic extensions and their
// supplement, combining marks supplement, Latin Extended-C, -D, -E, -F and -G, and the
// Latin ligatures of the alphabetic presentation forms.
0x1AB0..=0x1AFF
| 0x1D00..=0x1DFF
| 0x2C60..=0x2C7F
| 0xA720..=0xA7FF
| 0xAB30..=0xAB6F
| 0xFB00..=0xFB06
| 0x10780..=0x107BF
| 0x1DF00..=0x1DFFF => Self::Roman,
// ・ and ゠ sit in the katakana block but are punctuation.
0x30A0 | 0x30FB => Self::Punctuation,
// Hiragana, katakana, katakana phonetic extensions, half-width katakana (with its
// prolonged sound and voicing marks) and the kana supplements.
0x3041..=0x309F | 0x30A1..=0x30FF | 0x31F0..=0x31FF | 0xFF66..=0xFF9F | 0x1B000..=0x1B16F => Self::Kana,
// CJK symbols: 〄 〒 〓 〠 〶; ideographic space, 々 〆 〇 and the like stay with the base.
0x3004 | 0x3012 | 0x3013 | 0x3020 | 0x3036 => Self::Symbols,
0x3000 | 0x3005..=0x3007 | 0x3021..=0x302F | 0x3031..=0x3035 | 0x3038..=0x303C => Self::Kanji,
0x3001..=0x303F => Self::Punctuation,
// ※ is a symbol; the rest of General Punctuation (dashes, quotes, leaders) punctuation.
0x203B => Self::Symbols,
0x2010..=0x205E => Self::Punctuation,
// Letterlike symbols, number forms, arrows, math, technical, enclosed alphanumerics, box
// drawing, shapes, miscellaneous symbols and dingbats; enclosed CJK and CJK
// compatibility; full-width signs.
0x2100..=0x2BFF | 0x3200..=0x33FF | 0xFFE0..=0xFFEE => Self::Symbols,
// Full-width forms of the ASCII signs.
0xFF03..=0xFF06 | 0xFF0A | 0xFF0B | 0xFF1C..=0xFF1E | 0xFF20 | 0xFF3C | 0xFF3E | 0xFF5C | 0xFF5E => Self::Symbols,
// Full-width forms of the other ASCII punctuation, and the half-width CJK punctuation.
0xFF01..=0xFF0F | 0xFF1A..=0xFF1F | 0xFF3B..=0xFF40 | 0xFF5B..=0xFF65 => Self::Punctuation,
_ => Self::Kanji,
}
}
}
/// The font a composite font sets one class of characters in, and how.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CompositePart {
/// The font family and style.
pub font: String,
#[serde(default = "regular")]
pub style: String,
/// The size, in percent of the type's size.
#[serde(default = "hundred")]
pub size: f64,
/// How far the characters are raised from the baseline, in percent of the type's size
/// (negative lowers them).
#[serde(default)]
pub baseline: f64,
/// Horizontal and vertical scale, in percent (Kana, Roman and Numerals only).
#[serde(default = "hundred")]
pub h_scale: f64,
#[serde(default = "hundred")]
pub v_scale: f64,
/// Kana only: the characters are scaled from the center of the em box rather than from the
/// baseline.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub scale_at_center: bool,
}
fn hundred() -> f64 {
100.0
}
fn regular() -> String {
"Regular".into()
}
impl CompositePart {
/// The part a new composite font gives every class: `font` in `style` at full size, on the
/// baseline, unscaled.
pub fn new(font: impl Into<String>, style: impl Into<String>) -> Self {
Self { font: font.into(), style: style.into(), size: 100.0, baseline: 0.0, h_scale: 100.0, v_scale: 100.0, scale_at_center: false }
}
}
/// The six parts of a composite font, one per [`CharClass`].
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct CompositeParts {
pub kanji: CompositePart,
pub kana: CompositePart,
pub punctuation: CompositePart,
pub symbols: CompositePart,
pub roman: CompositePart,
pub numerals: CompositePart,
}
impl CompositeParts {
/// Every class set in `part`.
pub fn all(part: CompositePart) -> Self {
Self { kanji: part.clone(), kana: part.clone(), punctuation: part.clone(), symbols: part.clone(), roman: part.clone(), numerals: part }
}
pub fn get(&self, class: CharClass) -> &CompositePart {
match class {
CharClass::Kanji => &self.kanji,
CharClass::Kana => &self.kana,
CharClass::Punctuation => &self.punctuation,
CharClass::Symbols => &self.symbols,
CharClass::Roman => &self.roman,
CharClass::Numerals => &self.numerals,
}
}
pub fn get_mut(&mut self, class: CharClass) -> &mut CompositePart {
match class {
CharClass::Kanji => &mut self.kanji,
CharClass::Kana => &mut self.kana,
CharClass::Punctuation => &mut self.punctuation,
CharClass::Symbols => &mut self.symbols,
CharClass::Roman => &mut self.roman,
CharClass::Numerals => &mut self.numerals,
}
}
}
/// A composite font (Type › Composite Fonts…), saved with the document. Its name is the family
/// that type set in it names.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct CompositeFont {
pub name: String,
pub parts: CompositeParts,
}
/// The size a part can take, in percent of the type's size.
pub const COMPOSITE_SIZE: std::ops::RangeInclusive<f64> = 10.0..=400.0;
/// How far a part can raise or lower its characters, in percent of the type's size.
pub const COMPOSITE_BASELINE: std::ops::RangeInclusive<f64> = -100.0..=100.0;
/// The horizontal and vertical scale a part can take, in percent.
pub const COMPOSITE_SCALE: std::ops::RangeInclusive<f64> = 10.0..=400.0;
/// The longest composite font name, in characters.
pub const COMPOSITE_NAME_MAX: usize = 255;
/// The most composite fonts a document or a file keeps.
pub const COMPOSITE_FONTS_MAX: usize = 256;
impl CompositeFont {
/// A composite font named `name` setting every class in `font` and `style`.
pub fn new(name: impl Into<String>, font: impl Into<String>, style: impl Into<String>) -> Self {
Self { name: name.into(), parts: CompositeParts::all(CompositePart::new(font, style)) }
}
/// Do `a` and `b` name the same composite font: equal in any case?
pub fn same_name(a: &str, b: &str) -> bool {
a == b || a.to_lowercase() == b.to_lowercase()
}
/// The font of `fonts` (a document's composite fonts) named `name`, in any case.
pub fn named<'a>(fonts: &'a [CompositeFont], name: &str) -> Option<&'a CompositeFont> {
fonts.iter().find(|f| Self::same_name(&f.name, name))
}
/// `name` trimmed, when it can name a composite font: not empty, at most
/// [`COMPOSITE_NAME_MAX`] characters and without control characters.
pub fn clean_name(name: &str) -> Option<String> {
let name = name.trim();
let ok = !name.is_empty() && name.chars().count() <= COMPOSITE_NAME_MAX && !name.chars().any(char::is_control);
ok.then(|| name.to_string())
}
/// Why this composite font can't be used, if it can't: a name [`Self::clean_name`] refuses, a
/// part without a font, a value out of its range, or a scale or Scale at Center on a class
/// that has none.
pub fn check(&self) -> Result<(), String> {
if Self::clean_name(&self.name).as_deref() != Some(self.name.as_str()) {
return Err(format!("`{}` can't name a composite font", self.name));
}
for class in CharClass::ALL {
let p = self.parts.get(class);
let k = class.key();
if p.font.trim().is_empty() {
return Err(format!("{k}: give a font"));
}
let within = |v: f64, r: &std::ops::RangeInclusive<f64>| v.is_finite() && r.contains(&v);
if !within(p.size, &COMPOSITE_SIZE) {
return Err(format!("{k}: size must be {} to {}%", COMPOSITE_SIZE.start(), COMPOSITE_SIZE.end()));
}
if !within(p.baseline, &COMPOSITE_BASELINE) {
return Err(format!("{k}: baseline must be {} to {}%", COMPOSITE_BASELINE.start(), COMPOSITE_BASELINE.end()));
}
for v in [p.h_scale, p.v_scale] {
if !within(v, &COMPOSITE_SCALE) {
return Err(format!("{k}: scale must be {} to {}%", COMPOSITE_SCALE.start(), COMPOSITE_SCALE.end()));
}
if v != 100.0 && !class.scales() {
return Err(format!("{k}: only kana, roman and numerals can be scaled"));
}
}
if p.scale_at_center && class != CharClass::Kana {
return Err(format!("{k}: only kana can be scaled at the center"));
}
}
Ok(())
}
/// `fonts` without the ones that can't be used: those [`Self::check`] refuses, a later font
/// sharing an earlier one's name (in any case), a part set in one of the fonts, and those past
/// [`COMPOSITE_FONTS_MAX`]. `None` when every font can be used.
pub fn usable(fonts: &[CompositeFont]) -> Option<Vec<CompositeFont>> {
let mut names = std::collections::HashSet::new();
let mut kept: Vec<CompositeFont> =
fonts.iter().filter(|f| f.check().is_ok() && names.insert(f.name.to_lowercase())).take(COMPOSITE_FONTS_MAX).cloned().collect();
kept.retain(|f| CharClass::ALL.iter().all(|&c| !names.contains(&f.parts.get(c).font.to_lowercase())));
(kept.len() != fonts.len()).then_some(kept)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Each class takes its characters: digits and Latin letters are half-width numerals and
/// Roman, kana both widths, and the punctuation, symbols and full-width forms their own.
#[test]
fn characters_fall_in_their_classes() {
for (s, class) in [
("漢字国一龍〇々", CharClass::Kanji),
("AZa09 ", CharClass::Kanji),
("あをアンーアンー", CharClass::Kana),
("、。「」『』【】〜・!?():", CharClass::Punctuation),
("“”‘’—…", CharClass::Punctuation),
("※→★○〒+=$%①㈱", CharClass::Symbols),
("AZaz é!? ()", CharClass::Roman),
// Latin Extended-C, -D and -E, phonetic extensions and a ligature.
("\u{2C60}\u{A7A0}\u{AB30}\u{1D00}\u{FB01}", CharClass::Roman),
("0123456789", CharClass::Numerals),
] {
for c in s.chars() {
assert_eq!(CharClass::of(c), class, "{c:?} (U+{:04X})", c as u32);
}
}
}
/// A composite font is read and written by its fields' names, and a part's omitted values
/// take the defaults.
#[test]
fn composite_fonts_read_back_with_defaults() {
let mut f = CompositeFont::new("Mixed", "Source Sans 3", "Regular");
f.parts.kana.v_scale = 90.0;
f.parts.kana.scale_at_center = true;
f.parts.roman.baseline = -5.0;
let json = serde_json::to_value(&f).unwrap();
assert_eq!(json["parts"]["kana"]["vScale"], 90.0);
assert_eq!(json["parts"]["kana"]["scaleAtCenter"], true);
assert!(json["parts"]["roman"].get("scaleAtCenter").is_none());
assert_eq!(serde_json::from_value::<CompositeFont>(json).unwrap(), f);
let part: CompositePart = serde_json::from_value(serde_json::json!({"font": "Inter"})).unwrap();
assert_eq!(part, CompositePart::new("Inter", "Regular"));
}
/// Names are trimmed; values out of their ranges, and scales on classes that don't scale, are
/// refused.
#[test]
fn composite_fonts_check_their_values() {
assert_eq!(CompositeFont::clean_name(" Mixed ").as_deref(), Some("Mixed"));
for bad in ["", " ", "a\tb", &"x".repeat(COMPOSITE_NAME_MAX + 1)] {
assert_eq!(CompositeFont::clean_name(bad), None, "{bad:?}");
}
let ok = CompositeFont::new("Mixed", "Inter", "Regular");
assert_eq!(ok.check(), Ok(()));
let with = |f: fn(&mut CompositeParts)| {
let mut c = ok.clone();
f(&mut c.parts);
c.check()
};
assert!(with(|p| p.roman.size = 9.0).is_err());
assert!(with(|p| p.roman.size = f64::NAN).is_err());
assert!(with(|p| p.kana.baseline = 101.0).is_err());
assert!(with(|p| p.kana.h_scale = 401.0).is_err());
assert!(with(|p| p.kanji.v_scale = 90.0).is_err());
assert!(with(|p| p.symbols.h_scale = 90.0).is_err());
assert!(with(|p| p.roman.scale_at_center = true).is_err());
assert!(with(|p| p.numerals.font = " ".into()).is_err());
assert_eq!(with(|p| p.kana.scale_at_center = true), Ok(()));
assert_eq!(with(|p| p.numerals.h_scale = 50.0), Ok(()));
let mut spaced = ok.clone();
spaced.name = " Mixed".into();
assert!(spaced.check().is_err());
}
}
+11 -1
View File
@@ -2,7 +2,7 @@ use super::area::{AreaOptions, PathAlign, PathEffect};
use super::char_style::CharStyle;
use super::para_style::ParaStyle;
use super::runs::{RubySpan, TextKind, TextRun, move_anchors, reverse_from_end};
use super::tabs_lists::ParaList;
use super::tabs_lists::{ListCounts, ParaList};
use super::{Affine, PathData, Point, Rect, TextObject, TextStyleScale, Vec2};
impl TextObject {
@@ -93,10 +93,20 @@ impl TextObject {
system_layout: false,
fractional_widths: true,
list_count: 0,
list_level_counts: Vec::new(),
cached_bounds: None,
cached_baselines: Vec::new(),
}
}
/// The list counts the story's earlier frames carry into this frame at each level
/// ([`Self::list_count`] and [`Self::list_level_counts`]).
pub fn list_counts(&self) -> ListCounts {
ListCounts::from_parts(self.list_count, &self.list_level_counts)
}
/// Set the list counts the story's earlier frames carry into this frame ([`Self::list_counts`]).
pub fn set_list_counts(&mut self, counts: ListCounts) {
(self.list_count, self.list_level_counts) = counts.parts();
}
pub fn plain_text(&self) -> String {
self.runs.iter().map(|r| r.text.as_str()).collect()
}
+6
View File
@@ -258,6 +258,12 @@ impl ParaStyle {
}
}
/// The left indent the layout sets the paragraph at: its left indent, and for a paragraph in a
/// list at a level past the first, [`ParaList::level_indent`] more.
pub fn laid_out_left_indent(&self) -> f64 {
self.left_indent + self.list.map_or(0.0, |l| l.level_indent())
}
/// The leading of characters styled `st` in this paragraph, as the layout sets it: their own
/// leading, or for Auto leading the paragraph's Justification › Auto Leading share of their
/// size (exactly [`CharStyle::effective_leading`] at the default 120%, or when the share is
+145 -12
View File
@@ -155,14 +155,29 @@ fn roman(mut n: u32) -> String {
s
}
/// Where a list's marker stands before the first line's text ([`ParaList::align`]): the Bullets
/// and Numbering dialog's Alignment.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ListAlign {
/// The marker starts where the first line starts: at the left indent plus the first-line
/// indent.
#[default]
Left,
/// The marker ends where the first line's text starts, against the text.
Right,
}
/// The list a paragraph is in (Paragraph panel › Bullets and Numbered List). The layout draws the
/// list's marker before the paragraph's first line, in the font, size and color of the paragraph's
/// first character without its baseline shift, rotation, superscript or subscript, tracking, manual
/// kerning, tate-chu-yoko, underline and strikethrough ([`Self::marker_style`]), and the text after
/// it starts at the next tab stop, the left indent counting as a stop. The marker is not a
/// character of the text, and an empty paragraph has none. A saved list reads the fields it leaves
/// out as their defaults, and a build ignores the fields it doesn't know.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
/// it starts at the next tab stop, the left indent counting as a stop. The marker stands at the
/// first line's start, or against the text with [`ListAlign::Right`]. A paragraph at a level past
/// the first ([`Self::level`]) is set [`LIST_LEVEL_INDENT`] further in for each level. The marker
/// is not a character of the text, and an empty paragraph has none. A saved list reads the fields
/// it leaves out as their defaults, and a build ignores the fields it doesn't know.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(default)]
pub struct ParaList {
/// The bullet each paragraph of a bulleted list starts with. A numbered list keeps it for
@@ -172,17 +187,127 @@ pub struct ParaList {
/// numbered list.
#[serde(skip_serializing_if = "Option::is_none")]
pub number: Option<NumberStyle>,
/// Where the marker stands before the first line's text. The file saves it only when it is
/// [`ListAlign::Right`].
#[serde(skip_serializing_if = "crate::skip::is_default")]
pub align: ListAlign,
/// The paragraph's level in the list, 1 to [`LIST_LEVELS`] (Tab and Shift+Tab at the start of
/// the paragraph, Bullets and Numbering…'s Level): each level counts its own numbers
/// ([`Self::next_marker`]). A value outside that range is read as the nearest level
/// ([`Self::depth`]). The file saves it only when it is not 1.
#[serde(skip_serializing_if = "is_first_level", deserialize_with = "read_level")]
pub level: u8,
}
impl Default for ParaList {
/// A bulleted list with a dot, its marker aligned left, at level 1.
fn default() -> Self {
Self { bullet: Bullet::default(), number: None, align: ListAlign::default(), level: 1 }
}
}
/// Is `level` the first list level, which the file does not save?
fn is_first_level(level: &u8) -> bool {
*level == 1
}
/// Reads a saved list level, a whole number, as the nearest of 1 to [`LIST_LEVELS`], so a level
/// such as -1 or 300 opens the file at level 1 or 9. Anything else is an error.
fn read_level<'de, D: serde::Deserializer<'de>>(d: D) -> Result<u8, D::Error> {
let level = match serde_json::Value::deserialize(d)? {
serde_json::Value::Number(n) if n.is_i64() || n.is_u64() => n.as_i64().map_or(LIST_LEVELS as i64, |n| n.clamp(1, LIST_LEVELS as i64)),
_ => return Err(serde::de::Error::custom("a list level must be a whole number")),
};
Ok(level as u8)
}
/// Reads a type object's saved counts at list levels 2 on ([`ListCounts::from_parts`]), keeping
/// the first `LIST_LEVELS - 1` and skipping the rest of a longer list without storing it.
pub(super) fn read_level_counts<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<u32>, D::Error> {
struct Counts;
impl<'de> serde::de::Visitor<'de> for Counts {
type Value = Vec<u32>;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a list of list counts")
}
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut seq: A) -> Result<Vec<u32>, A::Error> {
let mut out = Vec::new();
while out.len() < LIST_LEVELS - 1 {
match seq.next_element::<u32>()? {
Some(c) => out.push(c),
None => return Ok(out),
}
}
while seq.next_element::<serde::de::IgnoredAny>()?.is_some() {}
Ok(out)
}
}
d.deserialize_seq(Counts)
}
/// How many levels a list has (Bullets and Numbering…'s Level: 1 to 9).
pub const LIST_LEVELS: usize = 9;
/// How much further in (pt) the layout sets a list paragraph for each level past the first: its
/// left indent counts that much more, and the marker and the text move with it.
pub const LIST_LEVEL_INDENT: f64 = 18.0;
/// The numbered paragraphs a story's list has counted at each level, level 1 first
/// ([`ParaList::next_marker`]).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct ListCounts(pub [u32; LIST_LEVELS]);
impl ListCounts {
/// The counts with `first` at level 1 and `deeper` at levels 2 on, a missing level at 0, as a
/// type object saves them ([`TextObject::list_count`], [`TextObject::list_level_counts`]).
pub fn from_parts(first: u32, deeper: &[u32]) -> Self {
let mut counts = [0; LIST_LEVELS];
counts[0] = first;
for (c, &d) in counts[1..].iter_mut().zip(deeper) {
*c = d;
}
Self(counts)
}
/// The count at level 1, and the counts at levels 2 on without the zeros that end them.
pub fn parts(&self) -> (u32, Vec<u32>) {
let deeper = &self.0[1..];
let used = deeper.iter().rposition(|&c| c != 0).map_or(0, |i| i + 1);
(self.0[0], deeper[..used].to_vec())
}
}
impl ParaList {
/// A bulleted list with `bullet`.
pub fn bulleted(bullet: Bullet) -> Self {
Self { bullet, number: None }
Self { bullet, ..Self::default() }
}
/// A numbered list with `style`.
pub fn numbered(style: NumberStyle) -> Self {
Self { bullet: Bullet::default(), number: Some(style) }
Self { number: Some(style), ..Self::default() }
}
/// This list with its marker at `align`.
pub fn aligned(self, align: ListAlign) -> Self {
Self { align, ..self }
}
/// This list at level `level` (1 to [`LIST_LEVELS`]).
pub fn at_level(self, level: u8) -> Self {
Self { level, ..self }
}
/// The list's level, 1 to [`LIST_LEVELS`]: [`Self::level`], a value outside the range taken
/// as the nearest level.
pub fn depth(&self) -> usize {
usize::from(self.level).clamp(1, LIST_LEVELS)
}
/// How much further in (pt) than its left indent the layout sets a paragraph of this list:
/// [`LIST_LEVEL_INDENT`] for each level past the first.
pub fn level_indent(&self) -> f64 {
(self.depth() - 1) as f64 * LIST_LEVEL_INDENT
}
/// The characters of the marker the layout draws before the paragraph: the bullet, or for a
@@ -195,18 +320,26 @@ impl ParaList {
}
/// The marker of the next paragraph of a story, whose list is `list` (None: in no list) and
/// which holds no text when `empty`, with `count` the numbered paragraphs counted before it.
/// A paragraph in no list sets the count back to 0 and has no marker; an empty paragraph has
/// none and leaves the count as it is; a bulleted paragraph has its bullet and leaves the
/// count as it is; a numbered paragraph adds one to the count and is numbered with it.
pub fn next_marker(list: Option<ParaList>, empty: bool, count: &mut u32) -> Option<String> {
/// which holds no text when `empty`, with `counts` the numbered paragraphs counted before it at
/// each level. A paragraph in no list sets every count back to 0 and has no marker; an empty
/// paragraph has none and leaves the counts as they are. A paragraph with text sets the counts
/// of the levels deeper than its own back to 0, so a deeper paragraph after it counts from 1
/// again; a bulleted one has its bullet and leaves the count of its level as it is, and a
/// numbered one adds one to the count of its level and is numbered with it. A paragraph back
/// at a shallower level then goes on with that level's count.
pub fn next_marker(list: Option<ParaList>, empty: bool, counts: &mut ListCounts) -> Option<String> {
let Some(list) = list else {
*count = 0;
*counts = ListCounts::default();
return None;
};
if empty {
return None;
}
let level = list.depth() - 1;
for deeper in &mut counts.0[level + 1..] {
*deeper = 0;
}
let count = &mut counts.0[level];
if list.number.is_some() {
*count = count.saturating_add(1);
}
+80 -2
View File
@@ -194,13 +194,13 @@ fn number_styles_write_their_numbers_and_roll_over() {
assert_eq!(ParaList::numbered(NumberStyle::UpperRoman).marker(4), "IV.");
assert_eq!(ParaList::bulleted(Bullet::Square).marker(4), "\u{25AA}");
let (num, dot) = (Some(ParaList::numbered(NumberStyle::Arabic)), Some(ParaList::bulleted(Bullet::Dot)));
let mut count = 0;
let mut count = ListCounts::default();
let markers: Vec<Option<String>> = [(num, false), (num, true), (dot, false), (num, false), (None, false), (num, false)]
.iter()
.map(|&(l, empty)| ParaList::next_marker(l, empty, &mut count))
.collect();
assert_eq!(markers, [Some("1.".into()), None, Some("\u{2022}".into()), Some("2.".into()), None, Some("1.".into())]);
assert_eq!(count, 1);
assert_eq!(count.parts(), (1, vec![]));
let read = |v: serde_json::Value| serde_json::from_value::<ParaList>(v).unwrap();
assert_eq!(read(serde_json::json!({"bullet": "dash"})), ParaList::bulleted(Bullet::Dash));
assert_eq!(read(serde_json::json!({"number": "threeDigits"})), ParaList::numbered(NumberStyle::ThreeDigits));
@@ -211,6 +211,20 @@ fn number_styles_write_their_numbers_and_roll_over() {
);
}
/// A list's marker alignment: Left by default and not saved, Right saved as `"align": "right"`;
/// a list saved without it reads as Left.
#[test]
fn a_list_saves_its_marker_alignment_only_when_it_is_right() {
let right = ParaList::numbered(NumberStyle::Arabic).aligned(ListAlign::Right);
assert_eq!(ParaList::bulleted(Bullet::Dot).align, ListAlign::Left);
assert_eq!(serde_json::to_value(right).unwrap(), serde_json::json!({"bullet": "dot", "number": "arabic", "align": "right"}));
assert_eq!(serde_json::to_value(ParaList::bulleted(Bullet::Dash)).unwrap(), serde_json::json!({"bullet": "dash"}));
let read = |v: serde_json::Value| serde_json::from_value::<ParaList>(v).unwrap();
assert_eq!(read(serde_json::json!({"bullet": "dot", "number": "arabic", "align": "right"})), right);
assert_eq!(read(serde_json::json!({"bullet": "square"})).align, ListAlign::Left);
assert!(serde_json::from_value::<ParaList>(serde_json::json!({"align": "center"})).is_err());
}
/// Typed list detection: "1.", "1)", "A." and "A)" and a space at the start of a paragraph
/// start a numbered list, and "*", "+", "#" and ">" and a space start a bulleted list with the
/// filled dot, the hollow circle, the filled square and the dash. Other text, the same characters
@@ -240,6 +254,70 @@ fn typed_list_starts_are_found_at_a_paragraphs_start() {
assert_eq!(t.typed_list_at(19), Some((16, letters)));
}
/// List levels: each level counts its own numbers, a deeper paragraph starts its count at 1,
/// a paragraph back at a shallower level goes on with that level's count, and a paragraph in
/// no list starts every level again. A level past the first sets the paragraph 18 pt further
/// in; the file saves the level only when it is not 1, and a level outside 1 to 9 reads as the
/// nearest one. A type object's counts save level 1 and the deeper levels apart.
#[test]
fn list_levels_count_their_own_numbers_and_save_only_past_the_first() {
let num = |level| Some(ParaList::numbered(NumberStyle::Arabic).at_level(level));
let dot = |level| Some(ParaList::bulleted(Bullet::Dot).at_level(level));
let mut counts = ListCounts::default();
let markers: Vec<Option<String>> = [
(num(1), false),
(num(2), false),
(num(2), false),
(num(3), false),
(num(2), true),
(num(2), false),
(num(1), false),
(num(2), false),
(dot(1), false),
(num(2), false),
(num(1), false),
(None, false),
(num(2), false),
]
.iter()
.map(|&(l, empty)| ParaList::next_marker(l, empty, &mut counts))
.collect();
let want = ["1.", "1.", "2.", "1.", "", "3.", "2.", "1.", "\u{2022}", "1.", "3.", "", "1."];
assert_eq!(markers.iter().map(|m| m.as_deref().unwrap_or("")).collect::<Vec<_>>(), want);
assert_eq!(counts, ListCounts([0, 1, 0, 0, 0, 0, 0, 0, 0]));
assert_eq!((ParaList::default().level, ParaList::bulleted(Bullet::Dot).depth()), (1, 1));
assert_eq!([0, 1, 4, 9, 12].map(|l| ParaList::bulleted(Bullet::Dot).at_level(l).depth()), [1, 1, 4, 9, 9]);
assert_eq!([1, 2, 9].map(|l| ParaList::bulleted(Bullet::Dot).at_level(l).level_indent()), [0.0, 18.0, 144.0]);
let para = ParaStyle { left_indent: 18.0, list: dot(3), ..Default::default() };
assert_eq!((para.laid_out_left_indent(), ParaStyle { list: None, ..para.clone() }.laid_out_left_indent()), (54.0, 18.0));
assert_eq!(serde_json::to_value(dot(1)).unwrap(), serde_json::json!({"bullet": "dot"}));
assert_eq!(serde_json::to_value(num(4)).unwrap(), serde_json::json!({"bullet": "dot", "number": "arabic", "level": 4}));
let read = |v: serde_json::Value| serde_json::from_value::<ParaList>(v).unwrap();
assert_eq!((read(serde_json::json!({"bullet": "dash"})).level, read(serde_json::json!({"level": 4}))), (1, dot(4).unwrap()));
assert!(serde_json::from_value::<ParaList>(serde_json::json!({"level": "two"})).is_err());
assert!(serde_json::from_value::<ParaList>(serde_json::json!({"level": 2.5})).is_err());
let levels = [serde_json::json!(0), serde_json::json!(-1), serde_json::json!(12), serde_json::json!(300), serde_json::json!(u64::MAX)];
assert_eq!(
levels.map(|l| read(serde_json::json!({ "level": l })).level),
[1, 1, 9, 9, 9],
"a saved level outside 1 to 9 reads as the nearest one"
);
let deep = ListCounts([5, 0, 2, 0, 0, 0, 0, 0, 0]);
assert_eq!(deep.parts(), (5, vec![0, 2]));
assert_eq!((ListCounts::from_parts(5, &[0, 2]), ListCounts::default().parts()), (deep, (0, vec![])));
let mut t = TextObject::point(Point::ZERO, "Text", CharStyle::default());
t.set_list_counts(deep);
assert_eq!((t.list_count, t.list_level_counts.as_slice(), t.list_counts()), (5, &[0, 2][..], deep));
let saved = serde_json::to_value(&t).unwrap();
assert_eq!((&saved["listCount"], &saved["listLevelCounts"]), (&serde_json::json!(5), &serde_json::json!([0, 2])));
t.set_list_counts(ListCounts::default());
assert!(serde_json::to_value(&t).unwrap().get("listLevelCounts").is_none(), "no counts past level 1 are saved without them");
let mut saved = serde_json::to_value(&t).unwrap();
saved["listLevelCounts"] = serde_json::json!((1..=20).collect::<Vec<u32>>());
let opened = serde_json::from_value::<TextObject>(saved).unwrap();
assert_eq!(opened.list_level_counts, (1..=8).collect::<Vec<u32>>(), "a file keeps only the counts of levels 2 to 9");
}
/// In a threaded frame whose first paragraph continues from the frame before, the text at the
/// frame's start is not the start of a paragraph, so typing a list's start there starts nothing.
/// The paragraphs after it still start lists.
+4 -3
View File
@@ -236,10 +236,11 @@ impl Carry {
self.0.is_empty()
}
/// Does it move any of the gradients of `ap`? Callers check first, so an appearance shared
/// with other objects is not copied for nothing.
/// Does it move any of the gradients of `ap` (those drawn at a place in document space, a
/// freeform gradient's own points included)? Callers check first, so an appearance shared with
/// other objects is not copied for nothing.
pub fn moves(&self, ap: &Appearance) -> bool {
!self.0.is_empty() && ap.has_placed_gradient()
!self.0.is_empty() && ap.has_positioned_gradient()
}
/// Move the placed gradients of `ap` along, through each step in turn.
+3 -3
View File
@@ -80,13 +80,13 @@ pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks};
pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace};
pub use raster::{RASTERIZE, RasterFx, Rasterize, color_param, outset, raster_effects};
pub use reshape::{
SYMBOL_ART_MAX_NODES, expand_outlined, has_gradient_copies, has_item_geometry, has_transform_art, has_transform_copies, has_transform_options,
image_paint, needs_outline, outline_art, outline_text, reshape,
SYMBOL_ART_MAX_NODES, expand_outlined, gives_image_shape, has_gradient_copies, has_item_geometry, has_transform_art, has_transform_copies,
has_transform_options, image_paint, needs_outline, outline_art, outline_text, reshape,
};
pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve};
pub use solids::{
EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SOLIDS, SolidMesh, THREE_D_TYPES, extrude_options, has_solid, inflate_options, is_three_d, solid_art,
three_d_meshes, three_d_params_as, validate_solid, validate_three_d, view_options,
three_d_meshes, three_d_meshes_of, three_d_params_as, validate_solid, validate_three_d, view_options,
};
pub use stylize::{ScribbleSetting, scribble_setting_id, scribble_settings};
pub use svg_filter::SVG_FILTER;
+21 -17
View File
@@ -133,7 +133,13 @@ fn gives_shape(e: &Effect) -> bool {
/// Does `item`, a fill or stroke of an image, paint something: does it paint and carry an effect
/// that gives it a shape ([`gives_shape`])?
fn shapes_image(item: &AppearanceItem) -> bool {
crate::paints(item) && item.effects().iter().any(gives_shape)
crate::paints(item) && gives_image_shape(item.effects())
}
/// Do `effects`, those of an image's fill or stroke, give that fill or stroke a shape to paint
/// ([`gives_shape`])?
pub fn gives_image_shape(effects: &[Effect]) -> bool {
effects.iter().any(gives_shape)
}
/// The fills and strokes of `items` (an image's own) that paint something ([`shapes_image`]), each
@@ -670,8 +676,7 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b
if follows {
group_marks(art, &mut vec![], &HashSet::new(), &mut groups);
}
// Whether a path kept its shape (past the budget), and the ids of those that did.
let mut kept = false;
// The ids of the paths that kept their shape (past the budget).
let mut kept_ids = HashSet::new();
let transform = (e.id == "distort.transform").then(|| {
let (m, copies) = crate::distort::transform_map(bounds, &params, seed);
@@ -697,7 +702,6 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b
let (made, moved) = transform_leaf(leaf, &path, map, copies, options, room);
used = used.minus(before).plus(made);
if !moved {
kept = true;
kept_ids.insert(leaf.id);
}
return;
@@ -709,12 +713,11 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b
}
}
let Some(out) = apply_one(&e.id, &params, &path, bounds, &GeomContext { seed, ..GeomContext::of(leaf) }, room) else {
kept = true;
kept_ids.insert(leaf.id);
return;
};
used = used.minus(before).plus(Budget::of(&out));
if follows && !boxes && leaf.appearance.has_placed_gradient() {
if follows && !boxes && leaf.appearance.has_positioned_gradient() {
crate::Carry::of(&e.id, &params, bounds, None, None, seed).apply(&mut leaf.appearance);
}
set_geometry(leaf, out, rule);
@@ -732,13 +735,13 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b
}
// What paints the art as a whole, and the own fills and strokes of the groups inside it,
// follow their bounds under Free Distort (they stay when no path changed them). Each stays
// when a path under it kept its shape (past the budget).
// when a path under it that its bounds measure kept its shape (past the budget).
let mut after = vec![];
let held = (!groups.is_empty() || !kept_ids.is_empty()) && group_marks(art, &mut vec![], &kept_ids, &mut after).1;
if !groups.is_empty() {
let mut after = vec![];
group_marks(art, &mut vec![], &kept_ids, &mut after);
carry_groups(art, groups, after, &|from, to| crate::Carry::of(&e.id, &params, bounds, from, to, seed));
}
if follows && !kept {
if follows && !held {
whole.push(crate::Carry::of(&e.id, &params, bounds, Some(bounds), art.geometric_bounds(), seed));
}
}
@@ -1020,7 +1023,7 @@ fn rebase_type_outlines(n: &mut Node, before: &mut std::vec::IntoIter<Option<Rec
&& from != to
{
for_each_leaf(n, &mut |leaf| {
if leaf.appearance.has_placed_gradient() {
if leaf.appearance.has_positioned_gradient() {
follow_boxes(&mut leaf.appearance, from, to);
}
});
@@ -1049,11 +1052,11 @@ fn is_member_group(n: &Node) -> bool {
/// A group below the art that [`is_member_group`] takes and whose own fills or strokes hold a
/// placed gradient: the child indices that lead to it from the art, its geometric bounds, and
/// whether a path below it kept its shape.
/// whether a path below it that its bounds measure kept its shape.
type GroupMark = (Vec<usize>, Option<Rect>, bool);
/// The geometric bounds of `n` (as [`Node::geometric_bounds`] gives them) and whether a path or
/// compound path below it is in `kept`, measured in one walk. Each group below `n` that
/// compound path below it that those bounds measure is in `kept`, measured in one walk. Each group below `n` that
/// [`GroupMark`] describes goes into `out`, reached from `n` through `at`.
fn group_marks(n: &Node, at: &mut Vec<usize>, kept: &HashSet<NodeId>, out: &mut Vec<GroupMark>) -> (Option<Rect>, bool) {
match &n.kind {
@@ -1065,14 +1068,15 @@ fn group_marks(n: &Node, at: &mut Vec<usize>, kept: &HashSet<NodeId>, out: &mut
for (i, c) in children.iter().enumerate() {
at.push(i);
let (b, h) = group_marks(c, at, kept, out);
if is_member_group(c) && c.appearance.has_placed_gradient() {
if is_member_group(c) && c.appearance.has_positioned_gradient() {
out.push((at.clone(), b, h));
}
at.pop();
held |= h;
// Only members measured in the bounds hold the group's gradients in place.
let counts = if *clip { i == 0 } else { i >= skip && (c.visible || !layer) };
if counts {
bounds = vectorcraft_geom::union_opt(bounds, b);
held |= h;
}
}
(bounds, held)
@@ -1126,7 +1130,7 @@ fn rebase_leaves(n: &mut Node, before: &mut std::vec::IntoIter<Option<Rect>>) {
if let Some(Some(from)) = before.next()
&& let Some(to) = n.geometric_bounds()
&& from != to
&& n.appearance.has_placed_gradient()
&& n.appearance.has_positioned_gradient()
{
follow_boxes(&mut n.appearance, from, to);
}
@@ -1224,7 +1228,7 @@ fn copy_placed(n: &mut Node, m: Affine, copies: usize, room: &mut usize) {
_ => {}
}
// The placed gradients of its own fills and strokes move with it, as a path's do.
if xf != Affine::IDENTITY && one.appearance.has_placed_gradient() {
if xf != Affine::IDENTITY && one.appearance.has_positioned_gradient() {
one.appearance.transform_gradients(xf);
}
one
+7 -4
View File
@@ -228,8 +228,9 @@ pub(crate) fn expand_revolve_art(n: &Node) -> Option<Node> {
/// The surfaces [`revolve_art`] draws, as meshes ([`crate::three_d_meshes`]): with the effect on
/// the object, one in its top visible fill (else its stroke); with it on fills and strokes, one for
/// each of them.
pub(crate) fn revolve_meshes(n: &Node) -> Result<Vec<crate::SolidMesh>, String> {
/// each of them. Each mesh adds its triangles to `total` as it is built, and the first that takes
/// `total` past `limit` stops with an error before the next is built.
pub(crate) fn revolve_meshes(n: &Node, total: &mut usize, limit: usize) -> Result<Vec<crate::SolidMesh>, String> {
let path = profile(n).ok_or("Revolve currently requires a path or compound path")?;
let ctx = GeomContext::of(n);
let mesh = |path: &PathData, fx: &Effect, item: &AppearanceItem| -> Result<crate::SolidMesh, String> {
@@ -250,7 +251,9 @@ pub(crate) fn revolve_meshes(n: &Node) -> Result<Vec<crate::SolidMesh>, String>
.find(|i| matches!(i, AppearanceItem::Fill(_)) && crate::paints(i))
.or_else(|| n.appearance.items.iter().rev().find(|i| crate::paints(i)))
.ok_or("the revolved object has no fill or stroke")?;
return Ok(vec![mesh(&path, fx, item)?]);
let mut out = vec![];
crate::solids::keep_mesh(&mut out, total, mesh(&path, fx, item)?, limit)?;
return Ok(out);
}
let path = geometry(&path, &n.appearance.effects, &ctx);
let mut out = vec![];
@@ -259,7 +262,7 @@ pub(crate) fn revolve_meshes(n: &Node) -> Result<Vec<crate::SolidMesh>, String>
&& let Some(fx) = item.effects().get(index)
{
let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(item));
out.push(mesh(&p, fx, item)?);
crate::solids::keep_mesh(&mut out, total, mesh(&p, fx, item)?, limit)?;
}
}
Ok(out)
+33 -15
View File
@@ -566,14 +566,15 @@ pub(crate) fn keep_mesh(out: &mut Vec<SolidMesh>, total: &mut usize, mesh: Solid
/// The solids effect `fx` builds from `shapes`, as [`art_for`] draws them, as meshes: Rotate turns
/// every fill and stroke; Extrude & Bevel and Inflate build one from each shape's top fill and one
/// from the outline of each stroke. A stroke whose solid is past the limits is left out.
fn meshes_for(fx: &Effect, shapes: &[Profile], total: &mut usize) -> Result<Vec<SolidMesh>, String> {
/// from the outline of each stroke. A stroke whose solid is past the limits is left out. Each mesh
/// is counted against `limit` ([`keep_mesh`]) before the next is built.
fn meshes_for(fx: &Effect, shapes: &[Profile], total: &mut usize, limit: usize) -> Result<Vec<SolidMesh>, String> {
let label = crate::effect_info(&fx.id).map_or("This effect", |e| e.label.trim_end_matches('…'));
let b = union_bounds(shapes).ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?;
let p = crate::merged_params(&fx.id, &fx.params);
let view = view_options(&p);
let mut out = vec![];
let mut keep = |out: &mut Vec<SolidMesh>, mesh: Mesh, color: [f32; 4]| keep_mesh(out, total, SolidMesh { mesh, color }, MAX_MESH_TRIANGLES);
let mut keep = |out: &mut Vec<SolidMesh>, mesh: Mesh, color: [f32; 4]| keep_mesh(out, total, SolidMesh { mesh, color }, limit);
if fx.id == ROTATE {
for s in shapes {
for item in &s.items {
@@ -585,8 +586,8 @@ fn meshes_for(fx: &Effect, shapes: &[Profile], total: &mut usize) -> Result<Vec<
return Ok(out);
}
let (parts, bounds) = solid_parts(shapes, b);
// Inflate lays out every puff at once (they share one eye); each mesh, and each Extrude &
// Bevel solid, is built as it is reached.
// Inflate lays out every puff at once (they share one eye); each Inflate and Extrude & Bevel
// solid and its mesh are built as the loop reaches them.
let mut inflated = (fx.id == INFLATE).then(|| {
let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule)| (area, *rule)).collect();
vectorcraft_three_d::inflate_meshes(&shapes, bounds, inflate_options(&p), view)
@@ -614,16 +615,33 @@ fn meshes_for(fx: &Effect, shapes: &[Profile], total: &mut usize) -> Result<Vec<
/// colors they are painted ([`SolidMesh`]), faces turned away from the viewer included; meshes
/// with no triangles are left out. On a group or layer, the effect's solids of its members
/// ([`members`]). The object's opacity multiplies every color's. An error when `n` carries no
/// such effect, its solids can't be built, or none has a triangle.
/// such effect, its solids can't be built, none has a triangle, or together they have more than
/// [`MAX_MESH_TRIANGLES`].
pub fn three_d_meshes(n: &Node) -> Result<Vec<SolidMesh>, String> {
// Triangles of every solid together, against [`MAX_MESH_TRIANGLES`].
meshes_within(n, &mut 0, MAX_MESH_TRIANGLES)
}
/// The meshes of every object in `nodes` ([`three_d_meshes`]), in order, as one 3D asset exports
/// them: the objects share one budget of [`MAX_MESH_TRIANGLES`].
pub fn three_d_meshes_of<'a>(nodes: impl IntoIterator<Item = &'a Node>) -> Result<Vec<SolidMesh>, String> {
meshes_of_within(nodes, MAX_MESH_TRIANGLES)
}
/// [`three_d_meshes_of`] with a budget of `limit` triangles.
fn meshes_of_within<'a>(nodes: impl IntoIterator<Item = &'a Node>, limit: usize) -> Result<Vec<SolidMesh>, String> {
let mut total = 0usize;
let mut out = vec![];
for n in nodes {
out.extend(meshes_within(n, &mut total, limit)?);
}
Ok(out)
}
/// [`three_d_meshes`], with the triangles of every solid added to `total` as it is built and an
/// error as soon as `total` passes `limit`.
fn meshes_within(n: &Node, total: &mut usize, limit: usize) -> Result<Vec<SolidMesh>, String> {
let meshes = if crate::has_revolve(n) {
let mut out = vec![];
for m in crate::revolve::revolve_meshes(n)? {
keep_mesh(&mut out, &mut total, m, MAX_MESH_TRIANGLES)?;
}
out
crate::revolve::revolve_meshes(n, total, limit)?
} else if !has_solid(n) {
return Err("the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate)".into());
} else if is_container(n) {
@@ -631,21 +649,21 @@ pub fn three_d_meshes(n: &Node) -> Result<Vec<SolidMesh>, String> {
let label = crate::effect_info(&fx.id).map_or("This effect", |e| e.label.trim_end_matches('…'));
let mut shapes = members(n, label)?;
reshape_members(n, n.appearance.effects.get(..index).unwrap_or_default(), &mut shapes);
meshes_for(fx, &shapes, &mut total)?
meshes_for(fx, &shapes, total, limit)?
} else {
let (path, rule) = profile(n).ok_or("3D export needs a path, compound path, type or group")?;
let ctx = GeomContext::of(n);
let paints = solid_paints(n);
if let Some((index, fx)) = solid(&n.appearance.effects) {
let path = geometry(&path, n.appearance.effects.get(..index).unwrap_or_default(), &ctx);
meshes_for(fx, &[Profile { path, rule, items: paints, opacity: 1.0 }], &mut total)?
meshes_for(fx, &[Profile { path, rule, items: paints, opacity: 1.0 }], total, limit)?
} else {
let path = geometry(&path, &n.appearance.effects, &ctx);
let mut out = vec![];
for item in &paints {
if let Some((index, fx)) = solid(item.effects()) {
let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(item));
out.extend(meshes_for(fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0 }], &mut total)?);
out.extend(meshes_for(fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0 }], total, limit)?);
}
}
out
+42 -2
View File
@@ -54,9 +54,49 @@ fn the_meshes_of_one_object_share_one_triangle_budget() {
let shapes = [Profile { path: profile(&n).unwrap().0, rule: FillRule::NonZero, items: solid_paints(&n), opacity: 1.0 }];
let Some(effect) = n.appearance.effects.first() else { panic!("no effect") };
let mut total = 0;
assert!(meshes_for(effect, &shapes, &mut total).is_ok());
assert!(meshes_for(effect, &shapes, &mut total, MAX_MESH_TRIANGLES).is_ok());
let mut total = MAX_MESH_TRIANGLES - first + 1;
assert!(meshes_for(effect, &shapes, &mut total).is_err_and(|e| e.contains("too complex")));
assert!(meshes_for(effect, &shapes, &mut total, MAX_MESH_TRIANGLES).is_err_and(|e| e.contains("too complex")));
}
#[test]
fn revolve_stops_at_the_first_mesh_past_the_budget() {
// The fill revolves into a mesh; the stroke's profile, copied 1000 times, is too complex to
// revolve at all.
let mut n = square_node(1, 100.0);
if let Some(AppearanceItem::Fill(f)) = n.appearance.items.get_mut(0) {
f.effects.push(fx(crate::REVOLVE, json!({})));
}
let fill_only = n.clone();
if let Some(AppearanceItem::Stroke(s)) = n.appearance.items.get_mut(1) {
s.effects.push(fx("distort.transform", json!({ "moveH": 1.0, "copies": 1000 })));
s.effects.push(fx(crate::REVOLVE, json!({})));
}
let fill = three_d_meshes(&fill_only).unwrap();
let triangles = fill[0].mesh.triangles.len();
assert!(triangles > 1);
let whole = three_d_meshes(&n);
assert!(whole.as_ref().is_err_and(|e| !e.contains("to export")), "the stroke's own error: {whole:?}");
// With room for less than the fill's mesh, the stroke's is never built.
let mut total = 0;
let capped = meshes_within(&n, &mut total, triangles - 1);
assert!(capped.as_ref().is_err_and(|e| e.contains("too complex to export")), "{capped:?}");
assert_eq!(total, triangles);
}
#[test]
fn the_objects_of_one_asset_share_one_triangle_budget() {
let mut a = square_node(1, 0.0);
a.appearance.effects.push(fx(EXTRUDE, json!({})));
let mut b = square_node(2, 300.0);
b.appearance.effects.push(fx(EXTRUDE, json!({})));
let triangles = |n: &Node| three_d_meshes(n).unwrap().iter().map(|m| m.mesh.triangles.len()).sum::<usize>();
let (ta, tb) = (triangles(&a), triangles(&b));
assert!(meshes_of_within([&a], ta).is_ok());
assert!(meshes_of_within([&b], tb).is_ok());
assert!(meshes_of_within([&a, &b], ta.max(tb)).is_err_and(|e| e.contains("too complex to export")));
assert_eq!(meshes_of_within([&a, &b], ta + tb).map(|m| m.len()), Ok(4));
assert_eq!(three_d_meshes_of([&a, &b]).map(|m| m.len()), Ok(4));
}
#[test]
+103 -19
View File
@@ -13,6 +13,8 @@
//! fill them ([`gradient_slices`]); Outline Stroke turns the strip into a gradient mesh
//! ([`gradient_meshes`]).
use std::collections::VecDeque;
use kurbo::{BezPath, Point, Shape, Vec2};
use vectorcraft_doc::color::{Gradient, GradientGeom, GradientPaint, Paint};
use vectorcraft_doc::live::{H_DOWN, H_LEFT, H_RIGHT, H_UP};
@@ -78,14 +80,26 @@ struct Lay<'a> {
st: &'a StrokeLayer,
/// Half the stroked width ([`aligned_width`]).
hw: f64,
/// How far ribs reach either side of the path where nothing wider is near.
cover: f64,
/// How far ribs reach past the stroke's edge.
margin: f64,
/// How far a strip runs on past the start and end of an open subpath (caps and heads).
ext: (f64, f64),
}
impl Lay<'_> {
/// How far the stroke reaches either side of the path at fraction `t` of a subpath: its wider
/// side, just before or just after a step there.
fn half(&self, t: f64) -> f64 {
let Some(p) = &self.st.profile else { return self.hw };
let ((bl, br), (al, ar)) = p.around(t);
self.hw * bl.max(br).max(al).max(ar).max(0.0)
}
/// How far a rib at fraction `t` reaches either side of the path to cover the stroke there.
fn cover(&self, t: f64) -> f64 {
self.half(t) + self.margin
}
/// The across band at fraction `t` of a subpath whose visible side is `side` (`Some(true)`:
/// left; `None`: both, a centred stroke).
fn band(&self, t: f64, side: Option<bool>) -> (f64, f64) {
@@ -147,10 +161,11 @@ fn strips(bp: &BezPath, rule: FillRule, st: &StrokeLayer, tol: f64, margin: f64,
if !(hw > 0.0 && hw.is_finite()) {
return vec![];
}
let full = hw * st.profile_max();
let heads = (st.head_reach(false), st.head_reach(true));
let cap = cap_extent(st.cap, 2.0 * full);
let lay = Lay { st, hw, cover: full + margin, margin, ext: (cap.max(heads.0) + margin, cap.max(heads.1) + margin) };
let mut lay = Lay { st, hw, margin, ext: (0.0, 0.0) };
// Caps reach as far past the ends as the stroke is wide there.
let cap = |t: f64| cap_extent(st.cap, 2.0 * lay.half(t));
lay.ext = (cap(0.0).max(heads.0) + margin, cap(1.0).max(heads.1) + margin);
let subs = subpaths(bp);
let count = subs.len();
let mut out = vec![];
@@ -165,7 +180,8 @@ fn strips(bp: &BezPath, rule: FillRule, st: &StrokeLayer, tol: f64, margin: f64,
// A subpath of no length: a dot (round and projecting caps paint it).
1 => {
let (d, b, e) = (Vec2::new(1.0, 0.0), lay.band(0.0, side), lay.ext.0.max(lay.ext.1));
vec![Rib::square(pts[0] - d * e, d, lay.cover, 0.0, b), Rib::square(pts[0] + d * e, d, lay.cover, 0.0, b)]
let r = lay.cover(0.0);
vec![Rib::square(pts[0] - d * e, d, r, 0.0, b), Rib::square(pts[0] + d * e, d, r, 0.0, b)]
}
_ => {
let total = length(&pts, closed);
@@ -215,9 +231,37 @@ fn strip(lay: &Lay, pts: &[Point], closed: bool, total: f64, side: Option<bool>,
let n = pts.len();
let seg_count = if closed { n } else { n - 1 };
let dir: Vec<Vec2> = (0..seg_count).map(|i| unit(pts[(i + 1) % n] - pts[i])).collect();
// Ribs near an arrowhead reach across it.
let reach = |s: f64| {
let mut r = lay.cover;
// How far each point is from the next, and along the subpath.
let seg: Vec<f64> = (0..n).map(|i| pts[i].distance(pts[(i + 1) % n])).collect();
let along: Vec<f64> = seg
.iter()
.scan(0.0, |s, l| {
let at = *s;
*s += l;
Some(at)
})
.collect();
// How far the ribs at point `i` reach: past the stroke there and on its way to the points
// either side, where it is no wider than at one of the two (each side of a width profile
// stays between its widths at the points of a span, and its points have points here). The
// caps and dots of a dashed stroke reach along the path as far as it is wide where they sit,
// so there the ribs cover the widest the stroke gets that far either way. Not as far as the
// stroke is wide anywhere: where a stroke crosses or runs close by itself, a narrow part
// reaching that far would paint over a wide part beside it (#5960). The start of a closed
// subpath is also its end. Ribs near an arrowhead reach across it.
let cover: Vec<f64> = (0..n).map(|i| if closed && i == 0 { lay.cover(0.0).max(lay.cover(1.0)) } else { lay.cover(along[i] / total) }).collect();
let window = match lay.st.dash {
Some(_) if lay.st.profile.is_some() => cap_extent(lay.st.cap, 2.0 * lay.hw * lay.st.profile_max()),
_ => 0.0,
};
let ahead = widest_ahead(&cover, &seg, closed, window);
// Behind a point is ahead of it on the subpath run backwards.
let back_seg: Vec<f64> = (0..n).map(|i| seg[(2 * n - 2 - i) % n]).collect();
let back_cover: Vec<f64> = cover.iter().rev().copied().collect();
let behind: Vec<f64> = widest_ahead(&back_cover, &back_seg, closed, window).into_iter().rev().collect();
let reach = |i: usize| {
let mut r = cover[i].max(ahead[i]).max(behind[i]);
let s = along[i];
if heads.0 > 0.0 && s <= heads.0 {
r = r.max(heads.0 + lay.margin);
}
@@ -227,29 +271,69 @@ fn strip(lay: &Lay, pts: &[Point], closed: bool, total: f64, side: Option<bool>,
r
};
let mut out = Vec::with_capacity(n + 4);
let mut s = 0.0;
if !closed {
let (d, b) = (dir[0], lay.band(0.0, side));
out.push(Rib::square(pts[0] - d * lay.ext.0, d, reach(0.0), 0.0, b));
out.push(Rib::square(pts[0], d, reach(0.0), 0.0, b));
s = pts[0].distance(pts[1]);
out.push(Rib::square(pts[0] - d * lay.ext.0, d, reach(0), 0.0, b));
out.push(Rib::square(pts[0], d, reach(0), 0.0, b));
}
let (first, last) = if closed { (0, n) } else { (1, n - 1) };
for i in first..last {
let v = i % n;
let t = s / total;
let ribs = lay.turn(pts[v], dir[(v + seg_count - 1) % seg_count], dir[v % seg_count], reach(s), t, lay.band(t, side));
let t = along[v] / total;
let ribs = lay.turn(pts[v], dir[(v + seg_count - 1) % seg_count], dir[v % seg_count], reach(v), t, lay.band(t, side));
// A closed subpath starts after the fan of its first corner and ends with it.
let skip = if closed && i == 0 { ribs.len() - 1 } else { 0 };
out.extend(ribs.into_iter().skip(skip));
s += pts[v].distance(pts[(v + 1) % n]);
}
if closed {
out.extend(lay.turn(pts[0], dir[seg_count - 1], dir[0], reach(total), 1.0, lay.band(1.0, side)));
out.extend(lay.turn(pts[0], dir[seg_count - 1], dir[0], reach(0), 1.0, lay.band(1.0, side)));
} else {
let (d, b) = (dir[seg_count - 1], lay.band(1.0, side));
out.push(Rib::square(pts[n - 1], d, reach(total), 1.0, b));
out.push(Rib::square(pts[n - 1] + d * lay.ext.1, d, reach(total), 1.0, b));
out.push(Rib::square(pts[n - 1], d, reach(n - 1), 1.0, b));
out.push(Rib::square(pts[n - 1] + d * lay.ext.1, d, reach(n - 1), 1.0, b));
}
out
}
/// For each of the points of a subpath, how far the ribs of the points ahead of it reach
/// (`cover`), out to the first point more than `window` along (or the end of an open subpath,
/// or once round a closed one): `seg[i]` is how far point `i` is from the next (past the last,
/// the first when `closed`). Both ends of the window only move forward, so a queue of the
/// points that reach farther than every point after them gives each maximum, and a subpath
/// costs time in proportion to its points however many a window spans.
pub(super) fn widest_ahead(cover: &[f64], seg: &[f64], closed: bool, window: f64) -> Vec<f64> {
let n = cover.len().min(seg.len());
if n == 0 {
return vec![];
}
// A closed subpath's points ahead are numbered on round it once more.
let last = if closed { 2 * n - 1 } else { n - 1 };
let mut pos = Vec::with_capacity(last + 1);
let mut s = 0.0;
for k in 0..=last {
pos.push(s);
s += seg[k % n];
}
let mut out = Vec::with_capacity(n);
let mut queue: VecDeque<usize> = VecDeque::new();
let mut next = 1;
for i in 0..n {
next = next.max(i + 1);
let stop = if closed { i + n } else { last };
let mut past = next > i + 1 && pos[next - 1] - pos[i] > window;
while !past && next <= stop {
let r = cover[next % n];
while queue.back().is_some_and(|&k| cover[k % n] <= r) {
queue.pop_back();
}
queue.push_back(next);
past = pos[next] - pos[i] > window;
next += 1;
}
while queue.front().is_some_and(|&k| k <= i) {
queue.pop_front();
}
out.push(queue.front().map_or(0.0, |&k| cover[k % n]));
}
out
}
+110
View File
@@ -237,3 +237,113 @@ fn slices_cover_an_arrowhead_on_a_long_straight_leg() {
}
}
}
/// Does the last slice holding `p` (the one filled last there) lay its gradient piece on a part of
/// the path running across the page (left or right), for a gradient in `mode`? Along, the piece
/// runs the way the path does; across, square to it.
fn painted_by_level_part(slices: &[Slice], p: Point, mode: StrokeGradientMode) -> Option<bool> {
slices.iter().rev().find(|s| s.shape.contains(p)).map(|s| {
let d = s.to - s.from;
(d.x.abs() > d.y.abs()) == (mode == StrokeGradientMode::Along)
})
}
/// #5960: where a variable-width stroke crosses itself, the later pass paints over the earlier one
/// only where the two overlap. Its slices reach as far as the stroke is wide there, not as far as
/// it is wide anywhere, so a narrow later pass leaves the wide earlier one its own colour beside it.
#[test]
fn a_narrow_pass_over_a_wide_one_paints_only_where_they_overlap() {
// Right along y = 50, round, then down through the first leg at x = 50, near the narrow end.
let crossing = poly(&[(0.0, 50.0), (100.0, 50.0), (100.0, 100.0), (50.0, 100.0), (50.0, 0.0)], false);
for mode in [StrokeGradientMode::Along, StrokeGradientMode::Across] {
let mut st = stroke(10.0, mode);
st.profile = Some(WidthProfile::taper_end());
let sl = slices(&crossing, &st);
// In the first leg (about 4 pt either side of it there) but 4 pt beside the second leg,
// which is less than 1 pt either side of itself there.
let level = painted_by_level_part(&sl, Point::new(54.0, 52.0), mode);
assert_eq!(level, Some(true), "{mode:?}: painted by the second leg");
// Where the two overlap, the second leg is on top.
let level = painted_by_level_part(&sl, Point::new(50.5, 52.0), mode);
assert_eq!(level, Some(false), "{mode:?}: painted by the first leg");
assert_covered(&crossing, &st);
}
}
/// #5960: a narrow pass running close beside a wide one doesn't paint the wide one's edge.
#[test]
fn a_narrow_pass_beside_a_wide_one_leaves_its_edge_alone() {
// Right along y = 0, then back along y = 7: the way back is the narrow end of a taper.
let hairpin = poly(&[(0.0, 0.0), (100.0, 0.0), (100.0, 7.0), (0.0, 7.0)], false);
let total = 207.0;
let mut st = stroke(10.0, StrokeGradientMode::Along);
st.profile = Some(WidthProfile::taper_end());
let sl = slices(&hairpin, &st);
// Half way along the first leg, 3 pt off it (it is about 3.8 pt wide there either side); the
// way back is about 1.2 pt wide either side there, so it reaches no nearer than 5.8.
let p = Point::new(50.0, 3.0);
let t = param(&sl, p).expect("in a slice");
assert!((t - 50.0 / total).abs() < 1e-6, "painted with {t}, not the first leg's {}", 50.0 / total);
assert_covered(&hairpin, &st);
}
/// #5960: with dashes, the ribs reach as far as the stroke gets wide within a cap's length along
/// the path, however many points lie that far: not as far as it is wide anywhere when more than
/// 256 do.
#[test]
fn a_densely_flattened_dashed_pass_beside_a_wide_one_leaves_its_edge_alone() {
// The hairpin above, the way back in 0.01 pt steps: 500 points within a round cap's 5 pt.
let mut pts = vec![(0.0, 0.0), (100.0, 0.0)];
pts.extend((0..=10_000).map(|i| (100.0 - i as f64 * 0.01, 7.0)));
let hairpin = poly(&pts, false);
let total = 207.0;
let mut st = stroke(10.0, StrokeGradientMode::Along);
st.profile = Some(WidthProfile::taper_end());
st.cap = LineCap::Round;
st.dash = Some(Dash { pattern: vec![12.0, 6.0], ..Default::default() });
let sl = slices(&hairpin, &st);
let p = Point::new(50.0, 3.0);
let t = param(&sl, p).expect("in a slice");
assert!((t - 50.0 / total).abs() < 1e-6, "painted with {t}, not the first leg's {}", 50.0 / total);
}
/// The widest the ribs ahead reach is the widest over the points out to the first one more than
/// the window along, as a walk along the subpath finds it, open and closed.
#[test]
fn widest_ahead_is_the_widest_within_the_window() {
let mut seed = 0x9e37_79b9_u64;
let mut rnd = move || {
seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407);
(seed >> 11) as f64 / (1u64 << 53) as f64
};
for _ in 0..200 {
let n = 1 + (rnd() * 40.0) as usize;
let cover: Vec<f64> = (0..n).map(|_| rnd() * 10.0).collect();
// Some points on top of each other.
let seg: Vec<f64> = (0..n).map(|_| if rnd() < 0.2 { 0.0 } else { rnd() * 3.0 }).collect();
let window = [0.0, rnd() * 5.0, rnd() * 200.0][(rnd() * 3.0) as usize];
for closed in [false, true] {
let got = super::gradient::widest_ahead(&cover, &seg, closed, window);
assert_eq!(got.len(), n);
for (i, &got) in got.iter().enumerate() {
let (mut j, mut d, mut want) = (i, 0.0, 0.0_f64);
for _ in 0..n {
let k = if closed {
(j + 1) % n
} else if j + 1 < n {
j + 1
} else {
break;
};
want = want.max(cover[k]);
d += seg[j];
j = k;
if d > window {
break;
}
}
assert_eq!(got, want, "n {n}, closed {closed}, window {window}, point {i}");
}
}
}
}
+60 -13
View File
@@ -57,16 +57,27 @@ fn numbers(v: &str) -> Option<Vec<f64>> {
/// Filter `xml` as it is drawn and written ([`rebuilt`]) when it passes [`check`], `None` when it
/// doesn't. Remembered for the filters seen last, so drawing does not check a filter again each time.
fn checked(xml: &str) -> Option<Arc<str>> {
use std::hash::BuildHasher;
type Seen = (std::collections::hash_map::RandomState, std::collections::HashMap<(u64, usize), Option<Arc<str>>>, usize);
static SEEN: std::sync::OnceLock<std::sync::Mutex<Seen>> = std::sync::OnceLock::new();
let seen = SEEN.get_or_init(Default::default);
remembered(&SEEN, xml, |xml| checked_form(xml).ok().map(|(_, form)| Arc::from(form)))
}
/// The results [`remembered`] keeps: the hash's seed, the results by key, and their size in bytes.
type Seen = (std::collections::hash_map::RandomState, std::collections::HashMap<(u64, usize), Option<Arc<str>>>, usize);
/// `make(xml)`, remembered in `seen` for the filters seen last.
fn remembered(
seen: &'static std::sync::OnceLock<std::sync::Mutex<Seen>>,
xml: &str,
make: impl FnOnce(&str) -> Option<Arc<str>>,
) -> Option<Arc<str>> {
use std::hash::BuildHasher;
let seen = seen.get_or_init(Default::default);
// Keyed by a hash that a file can't aim at (randomly seeded) and the length.
let key = seen.lock().ok().map(|s| (s.0.hash_one(xml), xml.len()));
if let Some(hit) = key.and_then(|k| seen.lock().ok().and_then(|s| s.1.get(&k).cloned())) {
return hit;
}
let form: Option<Arc<str>> = checked_form(xml).ok().map(|(_, form)| Arc::from(form));
let form = make(xml);
if let (Some(k), Ok(mut s)) = (key, seen.lock())
&& !s.1.contains_key(&k)
{
@@ -83,7 +94,7 @@ fn checked(xml: &str) -> Option<Arc<str>> {
form
}
/// The most check results [`checked`] keeps, and the most bytes of filters it keeps.
/// The most results [`remembered`] keeps in one place, and the most bytes of filters it keeps there.
const MAX_CHECKED: usize = 65_536;
const MAX_CHECKED_BYTES: usize = 32 << 20;
@@ -279,9 +290,30 @@ const LATER_BLEND_MODES: [&str; 11] =
/// The `feComposite` operators of SVG 1.1.
const SVG11_OPERATORS: [&str; 6] = ["over", "in", "out", "atop", "xor", "arithmetic"];
/// The longest form SVG output writes ([`svg11`]), leaving room within [`MAX_DRAWN_XML`] for the
/// region and id written into it.
const MAX_WRITTEN_XML: usize = MAX_DRAWN_XML - 1024;
/// The longest id SVG output gives a filter it writes: a unique-id prefix of up to 13 characters,
/// `filter-` and a counter of up to 20 digits, with room to spare.
const WRITTEN_ID_ROOM: usize = 64;
/// The longest text `{}` writes for a finite `f64`: a sign, `0.`, 307 zeros and 17 digits, as for
/// `-f64::MIN_POSITIVE`.
const MAX_NUMBER_TEXT: usize = 327;
/// The most the four region attributes written into a filter ([`SvgFilterFx::written_xml`]) add to
/// it: each of `x`, `y`, `width` and `height` as ` height="…"` with the longest number.
const REGION_ROOM: usize = 4 * (" height=\"\"".len() + MAX_NUMBER_TEXT);
/// The longest form SVG output writes ([`svg11`]): room is left within [`SvgFilter::MAX_XML`], the
/// longest filter SVG import reads, for the id written into it. Measured with [`written_len`].
const MAX_WRITTEN_XML: usize = SvgFilter::MAX_XML - WRITTEN_ID_ROOM;
/// How long `s` is as SVG output can write it and import reads it back: SVG output with the Latin-1
/// encoding writes each character past U+00FF as `&#x…;`, which import keeps as text, so the
/// longer of the two lengths is counted for each such character.
fn written_len(s: &str) -> usize {
// `&#x`, one hex digit per four significant bits, and `;`.
let reference = |c: u32| 4 + ((u32::BITS - c.leading_zeros()) as usize).div_ceil(4);
s.chars().map(|c| if u32::from(c) < 0x100 { c.len_utf8() } else { reference(u32::from(c)) }).sum()
}
/// Filter `xml` (a rebuilt one, [`rebuilt`]) as SVG 1.1 output writes it, in the form an SVG 1.1
/// viewer draws as resvg draws `xml`:
@@ -413,7 +445,7 @@ fn svg11(xml: &str) -> Option<String> {
}
out.push_str("</filter>");
}
(out.len() <= MAX_WRITTEN_XML).then_some(out)
(written_len(&out) <= MAX_WRITTEN_XML).then_some(out)
}
/// The result names of a filter's SVG 1.1 form ([`svg11`]): the names its elements use, and the
@@ -1278,13 +1310,28 @@ impl SvgFilterFx {
/// whose box in the document is `bounds`: in its SVG 1.1 form ([`svg11`]), `None` when SVG 1.1
/// can't hold it ([`Self::writes_as_svg11`]).
pub fn written_xml(&self, bounds: Rect) -> Option<String> {
self.with_region(&svg11(&self.xml)?, (bounds - self.origin).scale_from_origin(1.0 / self.scale))
let out = self.with_region(&self.svg11_form()?, (bounds - self.origin).scale_from_origin(1.0 / self.scale))?;
(written_len(&out) <= MAX_WRITTEN_XML).then_some(out)
}
/// Can SVG 1.1 output write the filter as a `<filter>` element ([`svg11`])? When it can't, SVG
/// output treats it as it treats the other pixel effects.
/// Can SVG 1.1 output write the filter as a `<filter>` element ([`svg11`]) that SVG import
/// reads back? When it can't, SVG output treats it as it treats the other pixel effects.
pub fn writes_as_svg11(&self) -> bool {
svg11(&self.xml).is_some()
self.svg11_form().is_some()
}
/// The filter's SVG 1.1 form ([`svg11`]) when, with any region and id that SVG output writes
/// into it, it is a filter SVG import reads ([`check`]): at most [`SvgFilter::MAX_XML`] bytes,
/// [`MAX_PRIMITIVES`] primitives and [`MAX_DEPTH`] levels deep, and drawn. Remembered for the
/// filters seen last, so export does not check a filter again for each object.
fn svg11_form(&self) -> Option<Arc<str>> {
static FORMS: std::sync::OnceLock<std::sync::Mutex<Seen>> = std::sync::OnceLock::new();
// `measured` follows from the XML, so the XML is the whole key.
let region = if self.measured { REGION_ROOM } else { 0 };
remembered(&FORMS, &self.xml, |xml| {
let form = svg11(xml)?;
(written_len(&form) + region <= MAX_WRITTEN_XML && check(&form).is_ok()).then(|| Arc::from(form))
})
}
/// How finely the filter runs on a raster whose pixels are `space.px` points wide, for an
+42 -3
View File
@@ -457,9 +457,48 @@ fn a_filter_at_the_size_limit_draws_with_its_measured_region() {
assert_eq!(xml.len(), SvgFilter::MAX_XML);
assert_eq!(check(&xml), Ok("r".into()));
let f = filter(json!({ "xml": xml }));
let written = f.written_xml(Rect::new(0.123456789, 0.123456789, 1000.987654321, 1000.987654321)).unwrap();
assert!(written.len() > SvgFilter::MAX_XML, "the region written out is longer: {} bytes", written.len());
assert!(with_id(&written, DRAWN_ID).is_some(), "the drawn form is read");
let drawn = f.drawn_xml(Rect::new(0.123456789, 0.123456789, 1000.987654321, 1000.987654321)).unwrap();
assert!(drawn.len() > SvgFilter::MAX_XML, "the region written out is longer: {} bytes", drawn.len());
assert!(with_id(&drawn, DRAWN_ID).is_some(), "the drawn form is read");
}
/// SVG output writes a filter live only when the form it writes, with its region and id, is one
/// SVG import reads back: at most [`SvgFilter::MAX_XML`] bytes. A filter at the limit whose region
/// is measured against the box, one at the limit whose id is a character long, and one whose
/// shadows make its SVG 1.1 form longer are left to be written as other pixel effects are.
#[test]
fn svg_output_writes_a_filter_only_in_a_form_import_reads() {
let at_limit = |open: &str, close: &str| {
let xml = format!("{open}{}{close}", "a".repeat(SvgFilter::MAX_XML - open.len() - close.len()));
assert_eq!((xml.len(), check(&xml).is_ok()), (SvgFilter::MAX_XML, true));
filter(json!({ "xml": xml }))
};
let measured = at_limit(
r#"<filter id="r" filterUnits="userSpaceOnUse" x="-1%" y="-1%" width="1%" height="1%"><feOffset result=""#,
r#""/><feOffset/></filter>"#,
);
let short_id = at_limit(r#"<filter id="r"><feOffset result=""#, r#""/><feOffset/></filter>"#);
let shadows = format!(r#"<filter id="s">{}<feOffset result="{}"/></filter>"#, "<feDropShadow/>".repeat(12), "a".repeat(SvgFilter::MAX_XML - 400));
assert!(check(&shadows).is_ok() && shadows.len() <= SvgFilter::MAX_XML);
for f in [measured, short_id, filter(json!({ "xml": shadows }))] {
assert!(!f.writes_as_svg11() && f.written_xml(Rect::new(0.0, 0.0, 10.0, 10.0)).is_none(), "{} bytes", f.xml().len());
}
// Room for the id and the longest region: still written, and read back.
let open = r#"<filter id="r" filterUnits="userSpaceOnUse" x="-1%" y="-1%" width="1%" height="1%"><feOffset result=""#;
let close = r#""/><feOffset/></filter>"#;
let xml = format!("{open}{}{close}", "a".repeat(MAX_WRITTEN_XML - REGION_ROOM - open.len() - close.len()));
let f = filter(json!({ "xml": xml }));
assert!(f.writes_as_svg11());
let id = format!("u_vc_unique_-filter-{}", usize::MAX);
let out = with_id(&f.written_xml(Rect::new(0.123456789, 0.123456789, 1000.987654321, 1000.987654321)).unwrap(), &id).unwrap();
assert!(out.len() <= SvgFilter::MAX_XML && check(&out).is_ok(), "{} bytes", out.len());
// The longest numbers a region can take still fit.
let far = f.written_xml(Rect::new(-f64::MIN_POSITIVE, -f64::MIN_POSITIVE, f64::MAX / 4.0, f64::MAX / 4.0)).unwrap();
assert!(with_id(&far, &id).is_some_and(|out| out.len() <= SvgFilter::MAX_XML));
for v in [-f64::MIN_POSITIVE, -5e-324, -2.225073858507201e-308, f64::MIN, -1.0 / 3.0] {
assert!(format!("{v}").len() <= MAX_NUMBER_TEXT, "{v:e}");
}
assert_eq!(format!("{}", -f64::MIN_POSITIVE).len(), MAX_NUMBER_TEXT);
}
/// Only a type selector naming a filter or one of its elements, or `*`, lets a style sheet reach
+86
View File
@@ -455,6 +455,92 @@ fn art_past_the_budget_keeps_its_own_gradients_in_place() {
}
}
/// A hidden path in a layer that keeps its shape past the budget does not hold the layer's
/// gradients in place: the layer's bounds leave it out, so the layer's own gradient moves with
/// the visible art under a Transform effect and Free Distort, as does the art's when the layer is
/// the art.
#[test]
fn a_hidden_path_past_the_budget_leaves_a_layers_gradients_free_to_move() {
use std::sync::Arc;
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
use vectorcraft_doc::{AppearanceItem, FillLayer, LayerColor, NodeId, NodeKind, color::Paint};
let stops = vec![GradientStop::new(0.0, vectorcraft_color::Color::BLACK), GradientStop::new(1.0, vectorcraft_color::Color::WHITE)];
let geom = GradientGeom { start: (0.0, 50.0).into(), end: (100.0, 50.0).into(), aspect: 1.0, focal: None };
let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..GradientPaint::new(Gradient::new(GradientKind::Linear, stops)) }));
let end = |ap: &Appearance| match ap.fill_paint() {
Paint::Gradient(g) => g.geom.map(|g| g.end.x),
_ => None,
};
// A layer with a visible square and a hidden circle, both 0..100, and a gradient fill of its own.
let layer = || {
let square = Arc::new(Node::path(NodeId(3), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default()));
let mut circle = Node::path(NodeId(4), shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default());
circle.visible = false;
let mut layer = Node::layer(NodeId(2), "Layer 1", LayerColor::Preset(0));
if let NodeKind::Layer { children, .. } = &mut layer.kind {
*children = vec![square, Arc::new(circle)];
}
layer.appearance.items.push(AppearanceItem::Fill(FillLayer::new(placed.clone())));
layer
};
// No room beyond what the paths cost: the square takes each effect, the circle would be drawn
// with more lines and keeps its shape.
let budget = Budget { anchors: usize::MAX, lines: 0 };
// Each scales the art ten times about its center: 0..100 becomes -450..550.
let corners = json!({"corners": [[-4.5, -4.5], [5.5, -4.5], [5.5, 5.5], [-4.5, 5.5]]});
for grow in [fx("distort.transform", json!({"scaleH": 1000, "scaleV": 1000})), fx("distort.freeDistort", corners)] {
let mut art = layer();
let carry = crate::reshape::reshape_leaves_within(&mut art, &[&grow], budget);
let circle = art.children().unwrap()[1].geometric_bounds().unwrap();
assert!(circle.width() < 101.0, "{}: the hidden circle keeps its shape: {circle:?}", grow.id);
let square = art.children().unwrap()[0].geometric_bounds().unwrap();
assert!((square.x1 - 550.0).abs() < 1e-6, "{}: the square takes the effect: {square:?}", grow.id);
let mut own = Appearance::clone(&art.appearance);
carry.apply(&mut own);
assert_eq!(end(&own), Some(550.0), "{}: the layer's gradient, as the art", grow.id);
let mut art = Node::group(NodeId(9), vec![Arc::new(layer())]);
crate::reshape::reshape_leaves_within(&mut art, &[&grow], budget);
assert_eq!(end(&art.children().unwrap()[0].appearance), Some(550.0), "{}: the layer's gradient, inside the art", grow.id);
}
}
/// A freeform gradient whose points were edited (its vector still fitted to the bounds) moves its
/// points with a Transform effect and Free Distort, on a path, on a group inside the art and on the
/// art itself, as a placed gradient does.
#[test]
fn edited_freeform_points_follow_a_transform_effect_and_free_distort() {
use std::sync::Arc;
use vectorcraft_color::{Color, Freeform, FreeformPoint, Gradient, GradientKind, GradientPaint};
use vectorcraft_doc::{AppearanceItem, FillLayer, NodeId, color::Paint};
let mut ramp = GradientPaint::new(Gradient { kind: GradientKind::Freeform, ..Gradient::default() });
let pt = |x: f64, c| FreeformPoint::new(Point::new(x, 50.0), c);
ramp.set_freeform(Freeform { points: vec![pt(0.0, Color::BLACK), pt(100.0, Color::WHITE)], ..Default::default() });
assert!(ramp.geom.is_none() && !ramp.is_placed());
let freeform = Paint::Gradient(Box::new(ramp));
let xs = |ap: &Appearance| match ap.fill_paint() {
Paint::Gradient(g) => g.freeform.as_ref().map(|f| f.points.iter().map(|p| p.at.x).collect::<Vec<_>>()),
_ => None,
};
let square = |ap: Appearance| Arc::new(Node::path(NodeId(3), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), ap));
// Each moves the art 30 pt right.
let corners = json!({"corners": [[0.3, 0.0], [1.3, 0.0], [1.3, 1.0], [0.3, 1.0]]});
for e in [fx("distort.transform", json!({"moveH": 30})), fx("distort.freeDistort", corners)] {
let mut inner = Node::group(NodeId(2), vec![square(Appearance::default())]);
inner.appearance.items.push(AppearanceItem::Fill(FillLayer::new(freeform.clone())));
let mut art = Node::group(NodeId(9), vec![square(Appearance::basic(freeform.clone(), Paint::None, 0.0)), Arc::new(inner)]);
art.appearance.items.push(AppearanceItem::Fill(FillLayer::new(freeform.clone())));
let carry = crate::reshape::reshape_leaves_within(&mut art, &[&e], Budget::EFFECTS);
let members = art.children().unwrap();
assert_eq!(xs(&members[0].appearance), Some(vec![30.0, 130.0]), "{}: the path's points", e.id);
assert_eq!(xs(&members[1].appearance), Some(vec![30.0, 130.0]), "{}: the inner group's points", e.id);
let mut own = Appearance::clone(&art.appearance);
carry.apply(&mut own);
assert_eq!(xs(&own), Some(vec![30.0, 130.0]), "{}: the art's own points", e.id);
}
}
/// #3578: a large Roughen draws each of its points as many lines; they stay within the budget,
/// the Roughen built again with fewer points where they wouldn't.
#[test]
+19 -3
View File
@@ -12,6 +12,7 @@ pub mod clipboard;
mod colorcmds;
pub mod colormgmt;
pub mod colortheme;
pub mod compositefonts;
mod compoundshape;
pub(crate) mod create;
mod css;
@@ -286,6 +287,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(inline::specs());
v.extend(textstyles::specs());
v.extend(fonts::specs());
v.extend(compositefonts::specs());
v.extend(fontfiles::specs());
v.extend(help::specs());
v.extend(threads::specs());
@@ -420,8 +422,22 @@ pub(crate) fn text_param<'a>(p: &'a Value, key: &str, cmd: &str) -> Result<Optio
pub(crate) fn id_param(p: &Value, key: &str) -> Option<NodeId> {
p.get(key).and_then(Value::as_u64).map(NodeId)
}
pub(crate) fn ids_param(p: &Value, key: &str) -> Option<Vec<NodeId>> {
p.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(NodeId).collect())
/// The object ids `p` gives for `key`, or `None` when `p` leaves it out or gives null. A value that
/// is not an array, or an entry that is not a non-negative integer, is an error with the message
/// [`checked_ids_param`] gives, so no entry is dropped. Whether each id names an object is left to
/// the caller.
pub(crate) fn ids_param(p: &Value, key: &str, cmd: &str) -> Result<Option<Vec<NodeId>>> {
let values = match p.get(key) {
None | Some(Value::Null) => return Ok(None),
Some(Value::Array(a)) => a,
Some(_) => return Err(bad(cmd, format!("{key} must be an array of non-negative integer object ids"))),
};
values.iter().enumerate().map(|(index, value)| id_entry(value, key, index, cmd)).collect::<Result<Vec<_>>>().map(Some)
}
/// Entry `index` (from 0) of the `key` list as an object id: anything but a non-negative integer is
/// an error naming the key, the entry and its value.
fn id_entry(value: &Value, key: &str, index: usize, cmd: &str) -> Result<NodeId> {
value.as_u64().map(NodeId).ok_or_else(|| bad(cmd, format!("`{key}` entry {index} is {value}, not a non-negative integer object id")))
}
/// The objects `p` gives `command`: its `ids`, else its `id` as `ids` of one (as [`targets`] reads
/// them), each checked ([`checked_id`]): a malformed one, or one naming no object, is an error,
@@ -447,7 +463,7 @@ pub(crate) fn index(v: u64) -> usize {
pub(crate) fn checked_ids_param(s: &Session, p: &Value, key: &str, command: &str) -> Result<Vec<NodeId>> {
let values =
p.get(key).and_then(Value::as_array).ok_or_else(|| bad(command, format!("{key} must be an array of non-negative integer object ids")))?;
values.iter().map(|value| checked_id(s, value, command)).collect()
values.iter().enumerate().map(|(index, value)| id_entry(value, key, index, command).and_then(|_| checked_id(s, value, command))).collect()
}
/// The objects a command acts on: `ids` when given (every one checked, and those inside another
+8 -8
View File
@@ -962,11 +962,9 @@ fn replace_fonts(s: &mut Session, p: &Value) -> Result<Value> {
let default = CharStyle::default().font_family;
let wanted = text_param(p, "font", C)?.filter(|f| !f.trim().is_empty()).unwrap_or(&default);
let db = FontDb::global();
// Any name of the family is stored as its own name.
let family = match db.resolve(wanted, "Regular") {
Some((f, m)) if m != FontMatch::Missing => f.family.clone(),
_ => return Err(bad(C, "the replacement font is not available")),
};
// Any name of the family is stored as its own name, and a composite font as its own name.
let composite = db.composite_font(wanted).is_some();
let family = db.stored_family(wanted, "Regular").ok_or_else(|| bad(C, "the replacement font is not available"))?;
let missing = |st: &CharStyle| db.resolve(&st.font_family, &st.font_style).is_none_or(|(_, m)| m == FontMatch::Missing);
let lacks = |t: &TextObject| t.runs.iter().any(|r| missing(&r.style));
let d = &s.doc()?.doc;
@@ -988,8 +986,11 @@ fn replace_fonts(s: &mut Session, p: &Value) -> Result<Value> {
let mut runs = 0;
let mut replace = |t: &mut TextObject| {
for r in t.runs.iter_mut().filter(|r| missing(&r.style)) {
// The style the font has closest to the one the character had.
r.style.font_style = db.face(&family, &r.style.font_style).map_or_else(|| r.style.font_style.clone(), |f| f.style.clone());
// The style the font has closest to the one the character had (a composite font's
// parts name their own styles: the character keeps its style).
if !composite {
r.style.font_style = db.face(&family, &r.style.font_style).map_or_else(|| r.style.font_style.clone(), |f| f.style.clone());
}
r.style.font_family = family.clone();
r.style.font_version = None;
r.style.font_axes.clear();
@@ -1004,7 +1005,6 @@ fn replace_fonts(s: &mut Session, p: &Value) -> Result<Value> {
}
}
edit_symbol_texts(d, None, &|t, _| lacks(t), &mut replace);
drop(replace);
Ok(runs)
})?;
Ok(json!({ "runs": runs, "font": family }))
+1 -1
View File
@@ -174,7 +174,7 @@ fn filter(s: &mut Session, p: &Value) -> Result<Value> {
let label = effects::effect_info(id).map_or("Apply as Filter", |e| e.label.trim_end_matches('…'));
let kinds = effects::addons::duotone::swatch_kinds(&s.doc()?.doc);
let swatches = |name: &str| kinds.get(name).copied();
recolor_links(s, &given, label, &|c| map.apply(c), &|c, link, tint| map.apply_link(c, link, tint, &swatches))
recolor_links(s, &given, C, label, &|c| map.apply(c), &|c, link, tint| map.apply_link(c, link, tint, &swatches))
}
/// Apply Fill to Stroke (`to_stroke`) or Apply Stroke to Fill: each target copies one of its own
@@ -189,7 +189,7 @@ pub(super) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
let given = json!({
"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "fill": fill, "stroke": stroke, "includeImages": false, "includePatterns": false,
});
let r = recolor_links(s, &given, "Apply Nearest Swatch", &|c| to(c).and_then(Paint::color).unwrap_or(c), &links)?;
let r = recolor_links(s, &given, APPLY, "Apply Nearest Swatch", &|c| to(c).and_then(Paint::color).unwrap_or(c), &links)?;
squash(s, undo, "Apply Nearest Swatch");
Ok(json!({ "changed": r["changed"], "swatches": names, "added": added }))
}
+1 -1
View File
@@ -1230,7 +1230,7 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
let selected = if defaults_only {
vec![]
} else {
match ids_param(p, "ids") {
match ids_param(p, "ids", C)? {
Some(v) => outermost(&s.doc()?.doc, v),
None => selected_roots(s)?,
}
+5 -6
View File
@@ -277,12 +277,11 @@ fn export_3d(s: &mut Session, p: &Value) -> Result<Value> {
for id in ids {
let Some(a) = d.asset(id) else { continue };
let format = a.format_3d.as_deref().unwrap_or(DEFAULT_FORMAT_3D);
let mut parts = vec![];
for node in &a.nodes {
let n = d.node(*node).ok_or_else(|| bad(C, format!("no such object {0}", node.0)))?;
let what = &a.name;
parts.extend(vectorcraft_render::effects::three_d_meshes(n).map_err(|e| bad(C, format!("{what} can't be exported in 3D: {e}")))?);
}
let nodes =
a.nodes.iter().map(|node| d.node(*node).ok_or_else(|| bad(C, format!("no such object {0}", node.0)))).collect::<Result<Vec<_>>>()?;
// The asset's objects share one budget of triangles.
let what = &a.name;
let parts = vectorcraft_render::effects::three_d_meshes_of(nodes).map_err(|e| bad(C, format!("{what} can't be exported in 3D: {e}")))?;
let base = Some(safe_file_name(&a.name)).filter(|b| !b.is_empty()).unwrap_or_else(|| "Asset".into());
let mut stem = base.clone();
let mut k = 1;
+1 -1
View File
@@ -263,7 +263,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Symbol Instance Appearance",
[],
None,
"{id?|ids?: [one id] (default: the one selected object), path: [i…] (the object in the symbol's art: child indices in paint order from the art's root down through its groups; [] the root), fill?: \"#rrggbb\"|{…paint params of paint.setFill: color, none, swatch, tint, gradient, keepModel}|null (null: the symbol's own again), stroke?: as fill, opacity?: 0..100|null, blend?: name|null} change the appearance of one object of a dynamic symbol instance's art, for that instance only: it stays linked, so editing the symbol reshapes it and keeps the change; refused for a static symbol or a path naming no object → {id, overrides: how many objects the instance changes}",
"{id?|ids?: [one id] (default: the one selected object), path: [i…] (the object in the symbol's art: child indices in paint order from the art's root down through its groups; [] the root), fill?: \"#rrggbb\"|{…paint params of paint.setFill: color, none, swatch, tint, gradient, keepModel}|null (null: the symbol's own again), stroke?: as fill, opacity?: 0..100|null, blend?: name|null} change the appearance of one object of a dynamic symbol instance's art, for that instance only: it stays linked, so editing the symbol reshapes it and keeps the change; refused for a static symbol, a path naming no object, or a fill or stroke other than null for an object that does not draw it (a guide, blend, envelope, Repeat, Intertwine group, gradient mesh or placed document; a symbol instance's stroke, and its fill unless a solid color or none, which stains its art; an image's fill or stroke without a Convert to Shape or Outline Object effect), with nothing of the call applied; opacity, blend and null are taken for every object → {id, overrides: how many objects the instance changes}",
has_selection,
symbol_instance_set_appearance
),
+60
View File
@@ -129,6 +129,64 @@ fn path_param(p: &Value, art: &Node, cmd: &str) -> Result<Vec<u32>> {
Ok(path)
}
/// The refusal of `key` (`fill` or `stroke`) for `n`, an object of a dynamic symbol's art, when `n`
/// does not draw that paint of its own; `None` when it does. Paths (not guides),
/// compound paths, type, groups and layers draw them; a symbol instance draws its fill as a stain
/// on its art ([`solid_fill_check`]) and no stroke; an image draws one only when that fill or
/// stroke has a Convert to Shape or Outline Object effect.
fn paint_refusal(n: &Node, key: &str, cmd: &str) -> Option<EngineError> {
Some(match &n.kind {
NodeKind::Path { guide: true, .. } => bad(cmd, format!("{key} cannot be set on a guide")),
NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_) | NodeKind::Group { .. } | NodeKind::Layer { .. } => return None,
NodeKind::Image(_) => {
let effects = if key == "fill" { n.appearance.fill().map(|f| &f.effects) } else { n.appearance.stroke().map(|s| &s.effects) };
if effects.is_some_and(|e| vectorcraft_render::effects::gives_image_shape(e)) {
return None;
}
bad(cmd, format!("{key} cannot be set on an image without a Convert to Shape or Outline Object effect on it"))
}
NodeKind::SymbolInstance { .. } if key == "fill" => return None,
NodeKind::SymbolInstance { .. } => bad(cmd, "stroke cannot be set on a symbol instance"),
NodeKind::Blend { .. } => bad(cmd, format!("{key} cannot be set on a blend")),
NodeKind::Envelope { .. } => bad(cmd, format!("{key} cannot be set on an envelope")),
NodeKind::Mesh(_) => bad(cmd, format!("{key} cannot be set on a gradient mesh")),
NodeKind::Repeat(_) => bad(cmd, format!("{key} cannot be set on a Repeat")),
NodeKind::PlacedDocument(_) => bad(cmd, format!("{key} cannot be set on a placed document")),
NodeKind::Intertwine { .. } => bad(cmd, format!("{key} cannot be set on an Intertwine group")),
})
}
/// Refuse a `fill` or `stroke` other than null that the object at `path` of `art` does not draw
/// ([`paint_refusal`]); null, which takes a change back, is taken for every object.
fn check_paint_kinds(p: &Value, art: &Node, path: &[u32], cmd: &str) -> Result<()> {
let Some(n) = symbol_overrides::part(art, path) else { return Ok(()) };
for key in ["fill", "stroke"] {
if p.get(key).is_some_and(|v| !v.is_null())
&& let Some(e) = paint_refusal(n, key, cmd)
{
return Err(e);
}
}
Ok(())
}
/// Refuse `fill`, the paint `p` asks for the object at `path` of `art`, when that object is a
/// symbol instance and `fill` is a gradient or a pattern: an instance stains its art with a solid
/// fill only, so such a fill draws nothing.
fn solid_fill_check(p: &Value, art: &Node, path: &[u32], fill: Option<&vectorcraft_color::Paint>, cmd: &str) -> Result<()> {
let asked = p.get("fill").is_some_and(|v| !v.is_null());
let is_instance = symbol_overrides::part(art, path).is_some_and(|n| matches!(n.kind, NodeKind::SymbolInstance { .. }));
let draws_nothing = |f: &vectorcraft_color::Paint| match f {
vectorcraft_color::Paint::Gradient(_) => true,
vectorcraft_color::Paint::Pattern { .. } => true,
vectorcraft_color::Paint::None | vectorcraft_color::Paint::Solid { .. } => false,
};
if asked && is_instance && fill.is_some_and(draws_nothing) {
return Err(bad(cmd, "fill on a symbol instance must be a solid color or none"));
}
Ok(())
}
/// `fill` or `stroke` of `p` for override `o`: absent leaves it, null clears it, a colour string or
/// an object of `paint.setFill`'s paint params sets it.
fn paint_change(s: &Session, p: &Value, key: &str, cmd: &str) -> Result<Option<Option<vectorcraft_color::Paint>>> {
@@ -170,9 +228,11 @@ pub(super) fn symbol_instance_set_appearance(s: &mut Session, p: &Value) -> Resu
}
let (id, art) = dynamic_instance(s, p, C)?;
let path = path_param(p, &art, C)?;
check_paint_kinds(p, &art, &path, C)?;
let d = &s.doc()?.doc;
let old = d.node(id).and_then(|n| n.extras().symbol_overrides.iter().find(|o| o.path == path).cloned());
let new = changed(s, p, old.unwrap_or_else(|| SymbolOverride { path: path.clone(), ..Default::default() }), C)?;
solid_fill_check(p, &art, &path, new.fill.as_ref(), C)?;
let count = edit_part(s, id, &path, "Symbol Instance Appearance", |o| *o = new)?;
Ok(json!({ "id": id.0, "overrides": count }))
}
+1 -1
View File
@@ -144,7 +144,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Make",
["Object", "Image Trace"],
None,
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), savedPreset?: {settings} (a saved preset's settings, which the journal records so a replay traces with them after the preset is changed, renamed or deleted; used in place of the saved preset `preset` names), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, at most ten; white is its own color, and a named color with every channel 245 or more is taken as that white, except with logoColors named, logoWhite false and no ignoreWhite; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white while white is its own color, and stay holes with ignoreWhite or without a white of its own; fills/strokes are ignored; gradients and photos are refused), logoWhite: true (mode logo with logoColors named; false traces the named colors only: white areas take the nearest of them, a named white is kept and enclosed transparent areas stay holes; with ignoreWhite, white areas are left out either way)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at)}",
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), savedPreset?: {settings} (a saved preset's settings, which the journal records so a replay traces with them after the preset is changed, renamed or deleted; used in place of the saved preset `preset` names), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), ignoreBackground: false (with ignoreWhite: only the areas of the ignored color that reach the image's edge are left out), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, at most ten; white is its own color, and a named color with every channel 245 or more is taken as that white, except with logoColors named, logoWhite false and no ignoreWhite; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white while white is its own color, and stay holes with ignoreWhite or without a white of its own; fills/strokes are ignored; gradients and photos are refused), logoWhite: true (mode logo with logoColors named; false traces the named colors only: white areas take the nearest of them, a named white is kept and enclosed transparent areas stay holes; with ignoreWhite, white areas are left out either way)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at)}",
has_selection_or_ids,
|s, p| trace_make(s, p, false)
),
+16 -15
View File
@@ -32,7 +32,7 @@ pub fn specs() -> Vec<CommandSpec> {
None,
"{fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?} replace each colour by its RGB inverse (keeps the colour model) in fills, strokes, text, gradient meshes (with fills), colorized image fills (their pixels stay unchanged), other embedded images (a recoloured copy) and pattern tiles (a new pattern swatch; the original stays), as one undo step → {changed}",
has_selection,
|s, p| recolor(s, p, "Invert Colors", &invert)
|s, p| recolor(s, p, "edit.colors.invert", "Invert Colors", &invert)
),
cmd!(
"edit.colors.toCMYK",
@@ -41,7 +41,7 @@ pub fn specs() -> Vec<CommandSpec> {
None,
"{fill?, stroke?, includeImages?, includePatterns?, ids?} (as edit.colors.invert) → {changed}",
has_selection,
|s, p| recolor(s, p, "Convert to CMYK", &to_cmyk)
|s, p| recolor(s, p, "edit.colors.toCMYK", "Convert to CMYK", &to_cmyk)
),
cmd!(
"edit.colors.toGrayscale",
@@ -50,7 +50,7 @@ pub fn specs() -> Vec<CommandSpec> {
None,
"{fill?, stroke?, includeImages?, includePatterns?, ids?} (as edit.colors.invert) → {changed}",
has_selection,
|s, p| recolor(s, p, "Convert to Grayscale", &to_gray)
|s, p| recolor(s, p, "edit.colors.toGrayscale", "Convert to Grayscale", &to_gray)
),
cmd!(
"edit.colors.toRGB",
@@ -60,7 +60,7 @@ pub fn specs() -> Vec<CommandSpec> {
"{fill?, stroke?, includeImages?, includePatterns?, ids?} (as edit.colors.invert) → {changed}",
has_selection,
|s, p| {
recolor(s, p, "Convert to RGB", &|c| {
recolor(s, p, "edit.colors.toRGB", "Convert to RGB", &|c| {
let [r, g, b] = c.to_rgb();
Color::rgb(r, g, b)
})
@@ -331,26 +331,27 @@ impl ColorVisitor for Visit<'_, '_> {
}
}
pub(crate) fn recolor(s: &mut Session, p: &Value, label: &str, f: &dyn Fn(Color) -> Color) -> Result<Value> {
run_pass(s, p, label, Recolor::new(Scope::of(p), f))
pub(crate) fn recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn(Color) -> Color) -> Result<Value> {
run_pass(s, p, cmd, label, Recolor::new(Scope::of(p), f))
}
/// [`recolor`] with solid colours and gradient stops mapped through `links`, with their swatch
/// link and tint.
pub(crate) fn recolor_links(s: &mut Session, p: &Value, label: &str, f: &dyn Fn(Color) -> Color, links: &LinkMap) -> Result<Value> {
run_pass(s, p, label, Recolor::with_links(Scope::of(p), f, links))
pub(crate) fn recolor_links(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn(Color) -> Color, links: &LinkMap) -> Result<Value> {
run_pass(s, p, cmd, label, Recolor::with_links(Scope::of(p), f, links))
}
/// Run `pass` on the `ids` param's objects (default: the selection) as one undo step.
fn run_pass(s: &mut Session, p: &Value, label: &str, pass: Recolor) -> Result<Value> {
let ids = match ids_param(p, "ids") {
/// Run `pass` on the `ids` param's objects (default: the selection) as one undo step named `label`;
/// errors name the command `cmd`.
fn run_pass(s: &mut Session, p: &Value, cmd: &str, label: &str, pass: Recolor) -> Result<Value> {
let ids = match ids_param(p, "ids", cmd)? {
Some(v) => v,
None => selected_roots(s)?,
};
let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok();
let n = s.edit(label, |d, _| {
let n = pass.run(d, &ids);
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(label, e))?;
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(cmd, e))?;
Ok(n)
})?;
Ok(json!({ "changed": n }))
@@ -358,7 +359,7 @@ fn run_pass(s: &mut Session, p: &Value, label: &str, pass: Recolor) -> Result<Va
fn saturate(s: &mut Session, p: &Value) -> Result<Value> {
let i = (f64_req(p, "intensity", "edit.colors.saturate")?.clamp(-100.0, 100.0) / 100.0) as f32;
recolor(s, p, "Saturate", &move |c| {
recolor(s, p, "edit.colors.saturate", "Saturate", &move |c| {
let [h, sat, v] = c.to_hsb();
let to = (sat * (1.0 + i)).clamp(0.0, 1.0);
// A color whose saturation stays the same is returned as it is, so it keeps its swatch link.
@@ -416,12 +417,12 @@ fn adjust_balance(s: &mut Session, p: &Value) -> Result<Value> {
(*tint, *c) = (t, base.tinted(t));
true
};
return run_pass(s, p, "Adjust Colors", Recolor::links(Scope::of(p), &shift));
return run_pass(s, p, C, "Adjust Colors", Recolor::links(Scope::of(p), &shift));
}
other => return Err(bad(C, format!("unknown mode `{other}` (rgb|cmyk|gray|global)"))),
};
let convert = bool_or(p, "convert", false);
recolor(s, p, "Adjust Colors", &move |c| {
recolor(s, p, C, "Adjust Colors", &move |c| {
let out = adjust(c);
if convert { out } else { keep_model(c, out) }
})
+2 -1
View File
@@ -287,7 +287,8 @@ fn assign_profile(s: &mut Session, p: &Value) -> Result<Value> {
/// How Document Color Mode converts neutral RGB greys (R = G = B) to CMYK (the `grays` param).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Grays {
/// Through the CMYK profile like any colour: four-colour greys and a rich black.
/// Through the CMYK profile like any colour: light grays in cyan, magenta and yellow only, darker
/// grays in four inks, and a rich black.
#[default]
Profile,
/// On the black plate only: K is the grey's ink percentage (as Convert to Grayscale and then
+309
View File
@@ -0,0 +1,309 @@
//! Type › Composite Fonts…: the document's composite fonts ([`vectorcraft_doc::CompositeFont`]),
//! which set each class of character (Kanji, Kana, Punctuation, Symbols, Roman, Numerals) in a
//! font of its own. Type names a composite font as its family, and the font menus list them first.
//! The font database holds the active document's composite fonts ([`sync`]); a file exported here
//! carries them to another document.
//!
//! Behavior reference (public documentation):
//! <https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=347>.
use serde_json::{Map, Value, json};
use vectorcraft_doc::text::{COMPOSITE_FONTS_MAX, COMPOSITE_NAME_MAX};
use vectorcraft_doc::{CharClass, CompositeFont, CompositePart, CompositeParts};
use vectorcraft_text::FontDb;
use super::fileio::{read_file_head, write_file};
use super::textstyles::each_text_mut;
use super::typecmd::refresh_bounds;
use super::{CommandSpec, Result, Session, bad, cmd, has_doc, switch_or, text_param};
/// The extension of the files Export writes and Import reads.
pub const EXTS: &[&str] = &["vccomposite"];
/// The largest file Import reads, in bytes.
const MAX_FILE_BYTES: usize = 4 << 20;
/// The keys a part may give.
const PART_KEYS: [&str; 7] = ["font", "style", "size", "baseline", "hScale", "vScale", "scaleAtCenter"];
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
query "type.compositeFonts.list",
"Composite Fonts",
[],
None,
"{} the active document's composite fonts → {fonts: [{name, parts: {kanji, kana, punctuation, symbols, roman, numerals: {font, style, size: % of the type size, baseline: % of the type size (positive raises), hScale: %, vScale: %, scaleAtCenter?: bool}}}]}",
has_doc,
list
),
cmd!(
"type.compositeFonts.set",
"Composite Fonts…",
["Type"],
None,
"{fonts: [{name, parts: {kanji: {font, style?: \"Regular\", size?: % 10..400 = 100, baseline?: % -100..100 = 0}, kana?, punctuation?, symbols?, roman?, numerals?: {font, style?, size?, baseline?, hScale?, vScale?: % 10..400 = 100, scaleAtCenter?: bool = false}}}]} replace the active document's composite fonts (Type › Composite Fonts…'s OK), as one undo step, none when nothing changes. A composite font sets each class of character in its part's font: kanji (the base: ideographs, full-width letters and digits and anything no other class takes), kana (hiragana and katakana, both widths), punctuation (East Asian and full-width punctuation, quotes and dashes), symbols (arrows, math, shapes and other symbols), roman (half-width letters, their punctuation and spaces) and numerals (the digits 0 to 9); a part left out is the kanji part. size and baseline are percent of the type's size, hScale and vScale scale kana, roman and numerals only, and scaleAtCenter (kana only) scales them from the em box's center instead of the baseline. Names are trimmed, unique in any case, at most 255 characters, and may not name an installed font; a part's font may not be a composite font; at most 256. Type set in a composite font names it as its family (text.setStyle {font: name}), and the font menus list composite fonts first. A font taken out stays named by its type, which then shows as missing → {fonts}",
has_doc,
set
),
cmd!(
query "type.compositeFonts.export",
"Export Composite Fonts",
[],
None,
"{path?: .vccomposite file, names?: [name] (default: all), fonts?: [{name, parts}] (as type.compositeFonts.set takes them)} write the active document's composite fonts (all, or those named, any case), or the fonts given, to a file type.compositeFonts.import reads; without path the file's text comes back as data → {path, count} | {data, count}",
has_doc,
export
),
cmd!(
"type.compositeFonts.import",
"Import Composite Fonts",
[],
None,
"{path: .vccomposite file | data: its text | dataBase64: its bytes (at most 4 MB), apply?: bool = true} read the composite fonts a file holds (type.compositeFonts.export writes them); with apply they join the active document's, a font of the same name (any case) replaced, as one undo step, none when nothing changes; apply false only reads them → {fonts, imported: [name]}",
has_doc,
import
),
]
}
/// Register the active document's composite fonts in the font database, and remove the ones the
/// other open documents (or an earlier active document) defined. Run after each command and when
/// another document takes the front, so an undo, a revert, an opened or closed file or a document
/// switch draws type in the active document's own composite fonts, and type naming one it does
/// not define shows as missing. Whether a composite font was added, changed or removed.
pub(crate) fn sync(s: &mut Session) -> bool {
let db = FontDb::global();
let mut names = Vec::new();
let mut changed = false;
for f in s.active().map(|d| d.doc.composite_fonts.to_vec()).unwrap_or_default() {
changed |= db.set_composite_font(&f);
names.push(f.name.to_lowercase());
}
let others = s.docs.iter().flat_map(|d| d.doc.composite_fonts.iter().map(|f| f.name.to_lowercase()));
let stale: Vec<String> = s.composite_names.iter().cloned().chain(others).filter(|n| !names.contains(n)).collect();
for gone in stale {
changed |= db.remove_composite_font(&gone);
}
s.composite_names = names;
changed
}
/// Leave out a file's unusable composite fonts before registering and measuring its type.
pub(crate) fn filter_loaded(doc: &mut vectorcraft_doc::Document) {
let db = FontDb::global();
if let Some(kept) = CompositeFont::usable(&doc.composite_fonts) {
*doc.composite_fonts = kept;
}
doc.composite_fonts.retain(|f| !db.has_installed_family(&f.name));
}
fn list(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!({ "fonts": *s.doc()?.doc.composite_fonts }))
}
/// Composite font `v` from command parameters or a file: its name trimmed, each part left out a
/// copy of the Kanji part, and keys or values a part can't take refused.
fn parse_font(v: &Value, c: &str) -> Result<CompositeFont> {
let o = v.as_object().ok_or_else(|| bad(c, "each font must be {name, parts}"))?;
if let Some(k) = o.keys().find(|k| !["name", "parts"].contains(&k.as_str())) {
return Err(bad(c, format!("unknown key `{k}`; a font takes name and parts")));
}
let raw = o.get("name").and_then(Value::as_str).ok_or_else(|| bad(c, "each font needs a name"))?;
let name = CompositeFont::clean_name(raw)
.ok_or_else(|| bad(c, format!("`{raw}` can't name a composite font (1 to {COMPOSITE_NAME_MAX} characters, no control characters)")))?;
let parts = o.get("parts").and_then(Value::as_object).ok_or_else(|| bad(c, format!("`{name}`: parts must be an object")))?;
if let Some(k) = parts.keys().find(|k| !CharClass::ALL.iter().any(|c| c.key() == k.as_str())) {
return Err(bad(c, format!("`{name}`: unknown class `{k}`; the classes are kanji, kana, punctuation, symbols, roman and numerals")));
}
let part = |class: CharClass| -> Result<Option<CompositePart>> {
let Some(p) = parts.get(class.key()) else { return Ok(None) };
let po = p.as_object().ok_or_else(|| bad(c, format!("`{name}`: {} must be an object", class.key())))?;
if let Some(k) = po.keys().find(|k| !PART_KEYS.contains(&k.as_str())) {
return Err(bad(c, format!("`{name}`: unknown key `{k}` in {}", class.key())));
}
serde_json::from_value(p.clone()).map(Some).map_err(|e| bad(c, format!("`{name}`: {}: {e}", class.key())))
};
let kanji = part(CharClass::Kanji)?.ok_or_else(|| bad(c, format!("`{name}`: give the kanji part")))?;
let mut f = CompositeFont { name: name.clone(), parts: CompositeParts::all(kanji) };
for class in CharClass::ALL.into_iter().skip(1) {
if let Some(p) = part(class)? {
*f.parts.get_mut(class) = p;
}
}
f.check().map_err(|e| bad(c, format!("`{}`: {e}", f.name)))?;
Ok(f)
}
/// Error unless `fonts` can stand together in a document: names unique in any case, none naming
/// an installed font, no part set in a composite font, and at most [`COMPOSITE_FONTS_MAX`].
fn check_set(fonts: &[CompositeFont], c: &str) -> Result<()> {
if fonts.len() > COMPOSITE_FONTS_MAX {
return Err(bad(c, format!("a document keeps at most {COMPOSITE_FONTS_MAX} composite fonts")));
}
let db = FontDb::global();
let mut names = std::collections::HashSet::new();
for f in fonts {
if !names.insert(f.name.to_lowercase()) {
return Err(bad(c, format!("two composite fonts are named `{}`", f.name)));
}
if db.has_installed_family(&f.name) {
return Err(bad(c, format!("`{}` is the name of an installed font", f.name)));
}
}
for f in fonts {
for class in CharClass::ALL {
let font = &f.parts.get(class).font;
if names.contains(&font.to_lowercase()) || db.composite_font(font).is_some() {
return Err(bad(c, format!("`{}`: {} is set in `{font}`, a composite font", f.name, class.key())));
}
}
}
Ok(())
}
/// Make `after` the active document's composite fonts, as one undo step named `label` (none when
/// they are what it has). The font database forgets the ones taken out, and type set in a font
/// that changed is measured again.
fn replace(s: &mut Session, label: &str, after: Vec<CompositeFont>) -> Result<()> {
let before = s.doc()?.doc.composite_fonts.to_vec();
if before == after {
return Ok(());
}
let changed: Vec<String> =
before.iter().filter(|b| !after.contains(b)).chain(after.iter().filter(|a| !before.contains(a))).map(|f| f.name.to_lowercase()).collect();
let db = FontDb::global();
for f in &before {
if !after.iter().any(|a| CompositeFont::same_name(&a.name, &f.name)) {
db.remove_composite_font(&f.name);
}
}
for f in &after {
db.set_composite_font(f);
}
let r = s.edit(label, |d, _| {
*d.composite_fonts = after.clone();
each_text_mut(d, &mut |t| {
if t.runs.iter().any(|r| changed.contains(&r.style.font_family.to_lowercase())) {
refresh_bounds(t);
}
Ok(())
})
});
sync(s);
r
}
fn set(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "type.compositeFonts.set";
let list = p.get("fonts").and_then(Value::as_array).ok_or_else(|| bad(C, "give fonts: [{name, parts}…]"))?;
let fonts = list.iter().map(|v| parse_font(v, C)).collect::<Result<Vec<_>>>()?;
check_set(&fonts, C)?;
replace(s, "Composite Fonts", fonts.clone())?;
Ok(json!({ "fonts": fonts }))
}
fn export(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "type.compositeFonts.export";
let path = text_param(p, "path", C)?;
if path.is_some_and(|p| p.trim().is_empty()) {
return Err(bad(C, "path is empty"));
}
let all = match p.get("fonts") {
None | Some(Value::Null) => s.doc()?.doc.composite_fonts.to_vec(),
Some(Value::Array(list)) => {
let fonts = list.iter().map(|v| parse_font(v, C)).collect::<Result<Vec<_>>>()?;
check_set(&fonts, C)?;
fonts
}
Some(_) => return Err(bad(C, "fonts must be a list of composite fonts")),
};
let fonts = match p.get("names") {
None | Some(Value::Null) => all,
Some(Value::Array(names)) => {
let mut out = Vec::with_capacity(names.len());
for n in names {
let n = n.as_str().ok_or_else(|| bad(C, "names must be strings"))?;
let f = all
.iter()
.find(|f| CompositeFont::same_name(&f.name, n.trim()))
.ok_or_else(|| bad(C, format!("no composite font named `{n}`")))?;
if !out.contains(f) {
out.push(f.clone());
}
}
out
}
Some(_) => return Err(bad(C, "names must be a list of names")),
};
if fonts.is_empty() {
return Err(bad(C, "no composite fonts to export"));
}
let mut file = Map::new();
file.insert("compositeFonts".into(), json!(fonts));
let text = serde_json::to_string_pretty(&file).map_err(|e| bad(C, e.to_string()))?;
match path {
Some(path) => {
write_file(path, text.as_bytes())?;
Ok(json!({ "path": path, "count": fonts.len() }))
}
None => Ok(json!({ "data": text, "count": fonts.len() })),
}
}
/// The composite fonts in the bytes of an exported file.
fn read_fonts(bytes: &[u8], c: &str) -> Result<Vec<CompositeFont>> {
let v: Value = serde_json::from_slice(bytes).map_err(|e| bad(c, format!("not a composite fonts file: {e}")))?;
let list = v.get("compositeFonts").and_then(Value::as_array).ok_or_else(|| bad(c, "not a composite fonts file: no compositeFonts list"))?;
if list.len() > COMPOSITE_FONTS_MAX {
return Err(bad(c, format!("the file holds more than {COMPOSITE_FONTS_MAX} composite fonts")));
}
list.iter().map(|f| parse_font(f, c)).collect()
}
fn import(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "type.compositeFonts.import";
let apply = switch_or(p, "apply", true, C)?;
let given = (text_param(p, "path", C)?, text_param(p, "data", C)?, text_param(p, "dataBase64", C)?);
let too_large = || bad(C, "the file is larger than 4 MB");
let bytes = match given {
(Some(path), None, None) if !path.trim().is_empty() => read_file_head(path, MAX_FILE_BYTES + 1)?,
(None, Some(text), None) => text.as_bytes().to_vec(),
(None, None, Some(b64)) => {
if b64.len() > MAX_FILE_BYTES / 3 * 4 + 4 {
return Err(too_large());
}
vectorcraft_format::base64_decode(b64).ok_or_else(|| bad(C, "dataBase64 is not base64"))?
}
_ => return Err(bad(C, "give one of path, data or dataBase64")),
};
if bytes.len() > MAX_FILE_BYTES {
return Err(too_large());
}
let fonts = read_fonts(&bytes, C)?;
if fonts.is_empty() {
return Err(bad(C, "the file holds no composite fonts"));
}
let mut names = std::collections::HashSet::new();
if let Some(f) = fonts.iter().find(|f| !names.insert(f.name.to_lowercase())) {
return Err(bad(C, format!("the file holds two composite fonts named `{}`", f.name)));
}
if apply {
let mut after = s.doc()?.doc.composite_fonts.to_vec();
for f in &fonts {
match after.iter_mut().find(|a| CompositeFont::same_name(&a.name, &f.name)) {
Some(a) => *a = f.clone(),
None => after.push(f.clone()),
}
}
check_set(&after, C)?;
replace(s, "Import Composite Fonts", after)?;
} else {
check_set(&fonts, C)?;
}
let imported: Vec<&str> = fonts.iter().map(|f| f.name.as_str()).collect();
Ok(json!({ "fonts": fonts, "imported": imported }))
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,502 @@
use serde_json::{Value, json};
use vectorcraft_doc::{NodeId, NodeKind};
use crate::Session;
/// A composite font named `name`: Source Sans 3 for the base, JetBrains Mono at half size for
/// Roman, Inter for numerals.
fn font(name: &str) -> Value {
json!({"name": name, "parts": {
"kanji": {"font": "Source Sans 3"},
"roman": {"font": "JetBrains Mono", "size": 50, "baseline": 10},
"numerals": {"font": "Inter", "hScale": 80},
}})
}
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
s
}
fn names(s: &Session) -> Vec<String> {
s.doc().unwrap().doc.composite_fonts.iter().map(|f| f.name.clone()).collect()
}
fn steps(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
fn text(s: &Session, id: u64) -> vectorcraft_doc::TextObject {
match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => (**t).clone(),
_ => panic!("not text"),
}
}
/// A scratch folder of the test's own.
fn folder(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("vectorcraft-composite-fonts-{name}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// Composite Fonts' OK replaces the document's composite fonts as one undo step (none when nothing
/// changes); parts left out copy the Kanji part; they are saved with the document and undone with
/// it.
#[test]
fn composite_fonts_are_set_as_one_undo_step_and_saved() {
let mut s = session();
let before = steps(&s);
let r = s.execute("type.compositeFonts.set", &json!({"fonts": [font(" CF Saved A "), font("CF Saved B")]})).unwrap();
assert_eq!(r["fonts"][0]["name"], "CF Saved A");
assert_eq!(names(&s), ["CF Saved A", "CF Saved B"]);
assert_eq!(steps(&s), before + 1);
let f = &s.doc().unwrap().doc.composite_fonts[0];
assert_eq!((f.parts.kana.font.as_str(), f.parts.roman.size, f.parts.numerals.h_scale), ("Source Sans 3", 50.0, 80.0));
// The same fonts again change nothing.
s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Saved A"), font("CF Saved B")]})).unwrap();
assert_eq!(steps(&s), before + 1);
let listed = s.execute("type.compositeFonts.list", &json!({})).unwrap();
assert_eq!(listed["fonts"].as_array().map(Vec::len), Some(2));
// They travel with the file.
let back = vectorcraft_format::load(&vectorcraft_format::save_file(&s.doc().unwrap().doc)).unwrap();
assert_eq!(*back.composite_fonts, *s.doc().unwrap().doc.composite_fonts);
s.execute("edit.undo", &json!({})).unwrap();
assert!(names(&s).is_empty());
}
/// Bad fonts are refused with an error and no undo step: a name that is empty, shared (in any
/// case) or an installed font's; a part set in a composite font; an unknown class or key; a value
/// out of range, of the wrong type, or on a class that can't take it; no Kanji part.
#[test]
fn bad_composite_fonts_are_refused() {
let mut s = session();
s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Refused Base")]})).unwrap();
let before = steps(&s);
let with = |f: &dyn Fn(&mut Value)| {
let mut v = font("CF Refused");
f(&mut v);
json!({"fonts": [v]})
};
for bad in [
json!({}),
json!({"fonts": "CF"}),
json!({"fonts": [font(" ")]}),
json!({"fonts": [font("CF Twice"), font("cf twice")]}),
json!({"fonts": [font("Source Sans 3")]}),
json!({"fonts": [font("Tab\tName")]}),
with(&|v| v["parts"]["roman"]["font"] = json!("CF Refused Base")),
with(&|v| v["parts"]["roman"]["font"] = json!("CF Refused")),
with(&|v| v["parts"]["hangul"] = json!({"font": "Inter"})),
with(&|v| v["parts"]["Roman"] = json!({"font": "Inter"})),
with(&|v| v["parts"]["roman"]["width"] = json!(3)),
with(&|v| v["parts"]["roman"]["size"] = json!(5)),
with(&|v| v["parts"]["roman"]["size"] = json!("50")),
with(&|v| v["parts"]["roman"]["baseline"] = json!(-120)),
with(&|v| v["parts"]["kanji"]["hScale"] = json!(90)),
with(&|v| v["parts"]["punctuation"] = json!({"font": "Inter", "vScale": 90})),
with(&|v| v["parts"]["roman"]["scaleAtCenter"] = json!(true)),
with(&|v| v["parts"]["kana"] = json!({"font": "Inter", "scaleAtCenter": "yes"})),
with(&|v| v["parts"]["roman"]["font"] = json!("")),
with(&|v| v["extra"] = json!(1)),
with(&|v| {
v["parts"].as_object_mut().unwrap().remove("kanji");
}),
] {
assert!(s.execute("type.compositeFonts.set", &bad).is_err(), "accepted {bad}");
assert_eq!(names(&s), ["CF Refused Base"]);
assert_eq!(steps(&s), before);
}
// Kana scaled at the center is allowed.
let ok = with(&|v| v["parts"]["kana"] = json!({"font": "Inter", "vScale": 80, "scaleAtCenter": true}));
s.execute("type.compositeFonts.set", &ok).unwrap();
assert_eq!(names(&s), ["CF Refused"]);
}
/// Type set in a composite font names it and is not missing; each class is drawn in its part's
/// font; changing the font measures the type again, and taking it out leaves the type naming it,
/// shown as missing.
#[test]
fn type_set_in_a_composite_font_follows_it() {
let mut s = session();
s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Type")]})).unwrap();
let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab12", "size": 20})).unwrap()["id"].as_u64().unwrap();
let r = s.execute("text.setStyle", &json!({"ids": [id], "font": "CF Type"})).unwrap();
assert!(r.get("warning").is_none(), "{r}");
let t = text(&s, id);
assert_eq!(t.runs[0].style.font_family, "CF Type");
let db = vectorcraft_text::FontDb::global();
let face = |family: &str| db.face(family, "Regular").unwrap().id();
let lay = vectorcraft_text::layout(db, &t);
let fonts: Vec<u32> = lay.glyphs.iter().map(|g| g.font_id).collect();
assert_eq!(fonts, [face("JetBrains Mono"), face("JetBrains Mono"), face("Inter"), face("Inter")]);
let missing = |s: &mut Session| s.execute("text.fonts", &json!({})).unwrap().as_array().unwrap().iter().filter(|f| f["missing"] == true).count();
assert_eq!(missing(&mut s), 0);
// Roman at full size makes the type wider, measured again in the same step.
let wide = t.cached_bounds.unwrap().width();
let mut full = font("CF Type");
full["parts"]["roman"]["size"] = json!(100);
let before = steps(&s);
s.execute("type.compositeFonts.set", &json!({"fonts": [full]})).unwrap();
assert_eq!(steps(&s), before + 1);
assert!(text(&s, id).cached_bounds.unwrap().width() > wide + 1.0);
s.execute("edit.undo", &json!({})).unwrap();
assert!((text(&s, id).cached_bounds.unwrap().width() - wide).abs() < 1e-6);
assert_eq!(db.composite_font("CF Type").unwrap().parts.roman.size, 50.0);
// Taken out, the type keeps the name, now missing; undone, the font is back.
s.execute("type.compositeFonts.set", &json!({"fonts": []})).unwrap();
assert!(db.composite_font("CF Type").is_none());
assert_eq!(text(&s, id).runs[0].style.font_family, "CF Type");
assert_eq!(missing(&mut s), 1);
s.execute("edit.undo", &json!({})).unwrap();
assert!(db.composite_font("CF Type").is_some());
assert_eq!(missing(&mut s), 0);
}
/// Type in a symbol's art is measured again too when the composite font it names changes.
#[test]
fn type_in_symbol_art_follows_its_composite_font() {
fn width(s: &Session) -> f64 {
fn find(n: &vectorcraft_doc::Node) -> Option<f64> {
if let NodeKind::Text(t) = &n.kind {
return t.cached_bounds.map(|b| b.width());
}
n.children().into_iter().flatten().find_map(|c| find(c))
}
s.doc().unwrap().doc.symbols.iter().find_map(|sym| find(&sym.art)).unwrap()
}
let mut s = session();
s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Symbol")]})).unwrap();
let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab12", "size": 20, "font": "CF Symbol"})).unwrap()["id"].as_u64().unwrap();
s.execute("symbol.new", &json!({"name": "CF Mark", "ids": [id]})).unwrap();
let narrow = width(&s);
let mut full = font("CF Symbol");
full["parts"]["roman"]["size"] = json!(100);
s.execute("type.compositeFonts.set", &json!({"fonts": [full]})).unwrap();
assert!(width(&s) > narrow + 1.0);
}
/// The font database forgets a composite font once no open document defines it: after the step
/// that added it is undone, and after its document closes.
#[test]
fn composite_fonts_no_document_defines_are_forgotten() {
let db = vectorcraft_text::FontDb::global();
let mut s = session();
s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Forgotten Undo")]})).unwrap();
assert!(db.composite_font("CF Forgotten Undo").is_some());
s.execute("edit.undo", &json!({})).unwrap();
assert!(db.composite_font("CF Forgotten Undo").is_none());
s.execute("edit.redo", &json!({})).unwrap();
assert!(db.composite_font("CF Forgotten Undo").is_some());
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Forgotten Close")]})).unwrap();
s.execute("file.close", &json!({})).unwrap();
assert!(db.composite_font("CF Forgotten Close").is_none());
assert!(db.composite_font("CF Forgotten Undo").is_some());
}
/// SVG and legacy `.ai` write type in a composite font as its outlines, since a reader can't find
/// the font by its name.
#[test]
fn exports_outline_type_in_a_composite_font() {
let mut s = session();
s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Export")]})).unwrap();
s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab1", "size": 20, "font": "CF Export"})).unwrap();
let svg = s.execute("document.export", &json!({"format": "svg"})).unwrap();
let svg = String::from_utf8(vectorcraft_format::base64_decode(svg["dataBase64"].as_str().unwrap()).unwrap()).unwrap();
assert!(!svg.contains("CF Export") && !svg.contains("<text") && svg.contains("<path"), "{svg}");
let ai = vectorcraft_eps::export_legacy_ai(&s.doc().unwrap().doc, &vectorcraft_eps::LegacyAiOptions::default()).unwrap();
assert!(!String::from_utf8_lossy(&ai.bytes).contains("CF Export"));
}
/// A document exported without a session (whose composite fonts the font database doesn't hold),
/// or while another document defines the name differently, still has its type in a composite font
/// written as outlines, drawn in its own definition's parts: SVG
/// writes the outlines the same type set in the part's font writes, PDF embeds the part's font, and
/// legacy `.ai` doesn't name the composite font.
#[test]
fn exports_draw_composite_fonts_of_a_document_without_a_session() {
let mut s = session();
let mono = json!({"name": "CF Standalone", "parts": {"kanji": {"font": "JetBrains Mono"}}});
s.execute("type.compositeFonts.set", &json!({"fonts": [mono]})).unwrap();
let id =
s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab1", "size": 20, "font": "CF Standalone"})).unwrap()["id"].as_u64().unwrap();
let doc = (*s.doc().unwrap().doc).clone();
// The font database forgets the font; the copy of the document still defines it.
s.execute("type.compositeFonts.set", &json!({"fonts": []})).unwrap();
let db = vectorcraft_text::FontDb::global();
assert!(db.composite_font("CF Standalone").is_none());
let paths = |svg: &str| svg.split(" d=\"").skip(1).filter_map(|p| p.split('"').next().map(str::to_string)).collect::<Vec<_>>();
let svg = vectorcraft_svg::export(&doc, &vectorcraft_svg::ExportOptions::default());
assert!(!svg.contains("CF Standalone") && !svg.contains("<text"), "{svg}");
s.execute("text.setStyle", &json!({"ids": [id], "font": "JetBrains Mono"})).unwrap();
let plain = vectorcraft_svg::export(&s.doc().unwrap().doc, &vectorcraft_svg::ExportOptions { outline_text: true, ..Default::default() });
assert!(!paths(&svg).is_empty());
assert_eq!(paths(&svg), paths(&plain));
let pdf = vectorcraft_pdf::export(&doc, &vectorcraft_pdf::PdfOptions::uncompressed()).unwrap();
assert!(String::from_utf8_lossy(&pdf).contains("JetBrainsMono"));
let ai = vectorcraft_eps::export_legacy_ai(&doc, &vectorcraft_eps::LegacyAiOptions::default()).unwrap();
assert!(!String::from_utf8_lossy(&ai.bytes).contains("CF Standalone"));
// The fonts lent for the exports are forgotten again.
assert!(db.composite_font("CF Standalone").is_none());
// With the front document defining the name differently, the exported document's own
// definition draws its type, and the front document's is put back.
let inter = json!({"name": "CF Standalone", "parts": {"kanji": {"font": "Inter"}}});
s.execute("type.compositeFonts.set", &json!({"fonts": [inter]})).unwrap();
let svg = vectorcraft_svg::export(&doc, &vectorcraft_svg::ExportOptions::default());
assert_eq!(paths(&svg), paths(&plain));
assert_eq!(db.composite_font("CF Standalone").unwrap().parts.kanji.font, "Inter");
}
/// Replace Font and Replace Missing Fonts set type in a composite font by the font's own name,
/// not its Kanji part's, and keep the type's style.
#[test]
fn replacing_a_font_with_a_composite_font_names_it() {
let mut s = session();
s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Replace")]})).unwrap();
let id =
s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab12", "size": 20, "font": "Source Serif 4"})).unwrap()["id"].as_u64().unwrap();
let r = s.execute("text.replaceFont", &json!({"from": {"family": "Source Serif 4"}, "to": {"family": "cf replace"}})).unwrap();
assert_eq!(r["runs"], 1, "{r}");
assert!(r.get("warning").is_none(), "{r}");
let t = text(&s, id);
assert_eq!((t.runs[0].style.font_family.as_str(), t.runs[0].style.font_style.as_str()), ("CF Replace", "Regular"));
let gone = s
.execute("text.create", &json!({"x": 10, "y": 90, "text": "Lost", "size": 20, "font": "No Such Font Family", "style": "Bold"}))
.unwrap()["id"]
.as_u64()
.unwrap();
let r = s.execute("addon.cleanup.replaceFonts", &json!({"font": "CF Replace"})).unwrap();
assert_eq!((r["runs"].as_u64(), r["font"].as_str()), (Some(1), Some("CF Replace")), "{r}");
let t = text(&s, gone);
assert_eq!((t.runs[0].style.font_family.as_str(), t.runs[0].style.font_style.as_str()), ("CF Replace", "Bold"));
}
/// When a part's font is missing, though the Kanji part's is installed, text.missingFonts names
/// that part's font for type with characters in that part, text.fonts shows the composite font as
/// missing (resolved in the fallback font that draws that part), and setting type in it warns;
/// missingGlyphs counts the characters that the font of their part lacks. Each run is checked on
/// its own, as layout splits it.
#[test]
fn font_inventory_checks_the_part_of_each_character() {
let mut s = session();
let mut f = font("CF Inventory");
f["parts"]["kanji"]["font"] = json!("Source Serif 4");
f["parts"]["roman"]["font"] = json!("No Such Part Font");
s.execute("type.compositeFonts.set", &json!({"fonts": [f]})).unwrap();
// Numerals are drawn in Inter: nothing is missing.
let r = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "12", "size": 20, "font": "CF Inventory"})).unwrap();
assert!(r.get("warning").is_none(), "{r}");
assert_eq!(s.execute("text.missingFonts", &json!({})).unwrap()["count"], 0);
// Roman letters are drawn in the missing font.
let r = s.execute("text.create", &json!({"x": 10, "y": 90, "text": "Ab", "size": 20, "font": "CF Inventory"})).unwrap();
assert!(r["warning"].as_str().is_some_and(|w| w.contains("No Such Part Font")), "{r}");
let missing = s.execute("text.missingFonts", &json!({})).unwrap();
let names: Vec<&str> = missing["fonts"].as_array().unwrap().iter().filter_map(|f| f["family"].as_str()).collect();
assert_eq!(names, ["No Such Part Font"], "{missing}");
let fonts = s.execute("text.fonts", &json!({})).unwrap();
let row = fonts.as_array().unwrap().iter().find(|r| r["family"] == "CF Inventory").cloned().unwrap();
assert_eq!((&row["missing"], &row["status"]), (&json!(true), &json!("missing")), "{row}");
assert_eq!(row["resolved"]["family"], vectorcraft_text::FALLBACK_FAMILY, "{row}");
// A run that starts with a combining mark after a run ending in kana: the mark is Roman.
let boundary = json!({"name": "CF Boundary", "parts": {"kanji": {"font": "Source Sans 3"}, "roman": {"font": "No Such Part Font"}}});
s.execute("type.compositeFonts.set", &json!({"fonts": [boundary]})).unwrap();
let id = s.execute("text.create", &json!({"x": 10, "y": 170, "text": "\u{304B}\u{301}", "size": 20, "font": "CF Boundary"})).unwrap()["id"]
.as_u64()
.unwrap();
let row =
|s: &mut Session| s.execute("text.fonts", &json!({})).unwrap().as_array().unwrap().iter().find(|r| r["family"] == "CF Boundary").cloned();
assert_eq!(row(&mut s).unwrap()["status"], "exact");
s.execute("text.setRangeStyle", &json!({"id": id, "start": 3, "end": 5, "size": 30})).unwrap();
assert_eq!(text(&s, id).runs.len(), 2);
assert_eq!(row(&mut s).unwrap()["status"], "missing");
// A symbol the Kanji part's font draws but the Symbols part's font lacks.
let db = vectorcraft_text::FontDb::global();
let (mono, sans) = (db.face("JetBrains Mono", "Regular").unwrap(), db.face("Source Sans 3", "Regular").unwrap());
let c = ('\u{2190}'..='\u{2BFF}').find(|c| mono.covers(*c) && !sans.covers(*c)).expect("a symbol only JetBrains Mono draws");
let glyphs = json!({"name": "CF Glyphs", "parts": {"kanji": {"font": "JetBrains Mono"}, "symbols": {"font": "Source Sans 3"}}});
s.execute("type.compositeFonts.set", &json!({"fonts": [glyphs]})).unwrap();
s.execute("text.create", &json!({"x": 10, "y": 130, "text": c.to_string(), "size": 20, "font": "CF Glyphs"})).unwrap();
let fonts = s.execute("text.fonts", &json!({})).unwrap();
let row = fonts.as_array().unwrap().iter().find(|r| r["family"] == "CF Glyphs").cloned().unwrap();
assert_eq!(row["missingGlyphs"], 1, "{row}");
}
/// The Glyphs panel's alternates of a character in a composite font come from the font of the
/// part its grapheme cluster falls in.
#[test]
fn glyph_alternates_of_composite_type_come_from_the_characters_part() {
let mut s = session();
s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Glyph Part")]})).unwrap();
let id =
s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab12", "size": 20, "font": "CF Glyph Part"})).unwrap()["id"].as_u64().unwrap();
let r = s.execute("text.glyphAlternates", &json!({"id": id, "start": 2})).unwrap();
assert_eq!(r["font"], "Inter", "{r}");
let r = s.execute("text.glyphAlternates", &json!({"id": id, "start": 0})).unwrap();
assert_eq!(r["font"], "JetBrains Mono", "{r}");
// A combining mark is drawn in the part of the character it follows.
let id = s.execute("text.create", &json!({"x": 10, "y": 90, "text": "1\u{301}", "size": 20, "font": "CF Glyph Part"})).unwrap()["id"]
.as_u64()
.unwrap();
let r = s.execute("text.glyphAlternates", &json!({"id": id, "start": 1})).unwrap();
assert_eq!(r["font"], "Inter", "{r}");
}
/// With two documents defining one name differently, the type is drawn in the front document's.
#[test]
fn the_front_document_defines_a_shared_name() {
let mut s = session();
s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Front")]})).unwrap();
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
let mut other = font("CF Front");
other["parts"]["roman"]["size"] = json!(75);
s.execute("type.compositeFonts.set", &json!({"fonts": [other]})).unwrap();
let db = vectorcraft_text::FontDb::global();
assert_eq!(db.composite_font("CF Front").unwrap().parts.roman.size, 75.0);
s.execute("document.activate", &json!({"index": 0})).unwrap();
assert_eq!(db.composite_font("CF Front").unwrap().parts.roman.size, 50.0);
}
/// A composite font only a document in the back defines is not available in the front one, where
/// type naming it shows as missing; a click on the back document's tab brings it back.
#[test]
fn a_font_only_a_back_document_defines_is_missing_in_front() {
let db = vectorcraft_text::FontDb::global();
let mut s = session();
s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Back Only")]})).unwrap();
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
assert!(db.composite_font("CF Back Only").is_none());
s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab", "size": 20, "font": "CF Back Only"})).unwrap();
let fonts = s.execute("text.fonts", &json!({})).unwrap();
assert!(fonts.as_array().unwrap().iter().any(|f| f["missing"] == true), "{fonts}");
assert!(s.set_active(0));
assert!(db.composite_font("CF Back Only").is_some());
}
/// A newly opened document measures a missing font without using the previous tab's composite
/// font of the same name.
#[test]
fn opened_document_measures_missing_composite_font_as_missing() {
let mut s = session();
s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Previous Tab")]})).unwrap();
let id =
s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab", "size": 20, "font": "CF Previous Tab"})).unwrap()["id"].as_u64().unwrap();
let mut doc = (*s.doc().unwrap().doc).clone();
doc.composite_fonts.clear();
if let NodeKind::Text(t) = &mut doc.node_mut(NodeId(id)).unwrap().kind {
t.cached_bounds = None;
}
s.add_document(doc, None);
assert!(s.set_active(0));
assert!(s.set_active(1));
let actual = text(&s, id);
let expected = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &actual).bounds;
assert_eq!(actual.cached_bounds, Some(expected));
}
/// A loaded composite font cannot replace an installed family used by existing type.
#[test]
fn loaded_composite_font_cannot_take_an_installed_family_name() {
let mut s = session();
let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab", "size": 20, "font": "Inter"})).unwrap()["id"].as_u64().unwrap();
let mut doc = (*s.doc().unwrap().doc).clone();
doc.composite_fonts.push(vectorcraft_doc::CompositeFont::new("Inter", "Source Sans 3", "Regular"));
if let NodeKind::Text(t) = &mut doc.node_mut(NodeId(id)).unwrap().kind {
t.cached_bounds = None;
}
s.add_document(doc, None);
assert!(names(&s).is_empty());
assert!(vectorcraft_text::FontDb::global().composite_font("Inter").is_none());
let actual = text(&s, id);
let expected = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &actual).bounds;
assert_eq!(actual.cached_bounds, Some(expected));
}
/// A file's composite fonts that Composite Fonts… would refuse (a value out of range, a name used
/// twice) are left out when the file opens.
#[test]
fn unusable_composite_fonts_in_a_file_are_left_out() {
let db = vectorcraft_text::FontDb::global();
let mut s = session();
let mut doc = (*s.doc().unwrap().doc).clone();
let mut wild = vectorcraft_doc::CompositeFont::new("CF Wild", "Source Sans 3", "Regular");
wild.parts.roman.size = 5000.0;
let good = vectorcraft_doc::CompositeFont::new("CF Kept", "Source Sans 3", "Regular");
let twice = vectorcraft_doc::CompositeFont::new("cf kept", "Inter", "Regular");
*doc.composite_fonts = vec![wild, good, twice];
s.add_document(doc, None);
assert_eq!(names(&s), ["CF Kept"]);
s.execute("document.activate", &json!({"index": 1})).unwrap();
assert!(db.composite_font("CF Wild").is_none());
assert_eq!(db.composite_font("CF Kept").unwrap().parts.roman.font, "Source Sans 3");
}
/// Export writes the document's composite fonts (all, or those named) to a file; Import reads it:
/// with apply false it only returns them, otherwise they join the document's, replacing a font of
/// the same name, as one undo step.
#[test]
fn composite_fonts_export_and_import() {
let dir = folder("io");
let path = dir.join("Mixed fonts (日本).vccomposite");
let path = path.to_str().unwrap();
let mut s = session();
assert!(s.execute("type.compositeFonts.export", &json!({"path": path})).is_err(), "nothing to export");
let mut slash = font("CF IO a/b: \"quoted\" 日本");
slash["parts"]["kana"] = json!({"font": "Inter", "vScale": 80, "scaleAtCenter": true});
s.execute("type.compositeFonts.set", &json!({"fonts": [slash, font("CF IO Other")]})).unwrap();
assert!(s.execute("type.compositeFonts.export", &json!({"path": path, "names": ["CF IO Nope"]})).is_err());
let r = s
.execute("type.compositeFonts.export", &json!({"path": path, "names": ["cf io a/b: \"quoted\" 日本", "CF IO a/b: \"quoted\" 日本"]}))
.unwrap();
assert_eq!(r["count"], 1);
let saved = s.doc().unwrap().doc.composite_fonts[0].clone();
let mut t = session();
let mut mine = font("CF IO a/b: \"quoted\" 日本");
mine["parts"]["roman"]["size"] = json!(90);
t.execute("type.compositeFonts.set", &json!({"fonts": [mine, font("CF IO Kept")]})).unwrap();
let before = steps(&t);
let read = t.execute("type.compositeFonts.import", &json!({"path": path, "apply": false})).unwrap();
assert_eq!(read["fonts"].as_array().map(Vec::len), Some(1));
assert_eq!(steps(&t), before);
t.execute("type.compositeFonts.import", &json!({"path": path})).unwrap();
assert_eq!(steps(&t), before + 1);
assert_eq!(names(&t), ["CF IO a/b: \"quoted\" 日本", "CF IO Kept"]);
assert_eq!(t.doc().unwrap().doc.composite_fonts[0], saved);
// The same file again changes nothing.
t.execute("type.compositeFonts.import", &json!({"path": path})).unwrap();
assert_eq!(steps(&t), before + 1);
// Files that aren't composite fonts, or are too large, are refused.
for (file, bytes) in [
("bad.vccomposite", b"not json".to_vec()),
("empty.vccomposite", br#"{"compositeFonts": []}"#.to_vec()),
("twice.vccomposite", format!(r#"{{"compositeFonts": [{}, {}]}}"#, font("CF Dup"), font("cf dup")).into_bytes()),
("large.vccomposite", vec![b' '; (4 << 20) + 1]),
] {
let p = dir.join(file);
std::fs::write(&p, bytes).unwrap();
assert!(t.execute("type.compositeFonts.import", &json!({"path": p.to_str().unwrap()})).is_err(), "{file}");
assert_eq!(steps(&t), before + 1);
}
assert!(t.execute("type.compositeFonts.import", &json!({"path": path, "apply": "no"})).is_err());
assert!(t.execute("type.compositeFonts.import", &json!({"path": ""})).is_err());
assert!(t.execute("type.compositeFonts.import", &json!({"path": path, "data": "{}"})).is_err());
assert!(t.execute("type.compositeFonts.import", &json!({"dataBase64": "%%%"})).is_err());
// Without a path, Export gives the file's text, which Import takes as data.
let data = s.execute("type.compositeFonts.export", &json!({"names": ["CF IO Other"]})).unwrap();
assert_eq!(data["count"], 1);
let mut u = session();
let r = u.execute("type.compositeFonts.import", &json!({"data": data["data"]})).unwrap();
assert_eq!(r["imported"], json!(["CF IO Other"]));
assert_eq!(names(&u), ["CF IO Other"]);
// Fonts given are written in place of the document's; bad ones are refused.
let given = s.execute("type.compositeFonts.export", &json!({"fonts": [font("CF IO Given")]})).unwrap();
assert!(given["data"].as_str().unwrap().contains("CF IO Given"));
assert!(s.execute("type.compositeFonts.export", &json!({"fonts": [font("")]})).is_err());
assert!(s.execute("type.compositeFonts.export", &json!({"fonts": []})).is_err());
assert!(s.execute("type.compositeFonts.export", &json!({"path": " "})).is_err());
let _ = std::fs::remove_dir_all(&dir);
}
+1 -1
View File
@@ -743,7 +743,7 @@ fn text_create(s: &mut Session, p: &Value) -> Result<Value> {
let mut made = super::typecmd::with_overflow(s, id.as_slice(), made);
if p.get("font").is_some() || p.get("style").is_some() {
let runs = match id.and_then(|i| s.doc().ok()?.doc.node(i)).map(|n| &n.kind) {
Some(NodeKind::Text(t)) => t.runs.iter().map(|r| super::fonts::font_of(&r.style)).collect(),
Some(NodeKind::Text(t)) => t.runs.iter().flat_map(super::fonts::run_fonts).collect(),
_ => Default::default(),
};
if let Some(w) = super::fonts::set_font_warning(runs) {
+1 -1
View File
@@ -256,7 +256,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Document Color Mode",
["File", "Document Color Mode"],
None,
"{mode: \"cmyk\"|\"rgb\", convert?: true (convert every color of the art (opacity masks, effect colors and graphs' series and label paints included), symbols, graph designs, pattern tiles, graphic styles (their effect colors included), brushes, swatches and the paints new art takes through the color settings, together in one undo step (Undo restores their original values; Redo reapplies the conversion); swatch links kept; Gray colors stay Gray, on the black plate), intent?, grays?: \"profile\" (default: RGB grays separate through the CMYK profile like any color, into four-color grays and a rich black) | \"black\" (to CMYK, RGB grays with R = G = B go on the black plate only, K = their gray value)} → {changed: how many colors of the art, symbols, graph designs and pattern tiles changed; the colors of graphic styles and brushes are not counted}",
"{mode: \"cmyk\"|\"rgb\", convert?: true (convert every color of the art (opacity masks, effect colors and graphs' series and label paints included), symbols, graph designs, pattern tiles, graphic styles (their effect colors included), brushes, swatches and the paints new art takes through the color settings, together in one undo step (Undo restores their original values; Redo reapplies the conversion); swatch links kept; Gray colors stay Gray, on the black plate), intent?, grays?: \"profile\" (default: RGB grays separate through the CMYK profile like any color, into light grays in cyan, magenta and yellow only, darker four-color grays and a rich black) | \"black\" (to CMYK, RGB grays with R = G = B go on the black plate only, K = their gray value)} → {changed: how many colors of the art, symbols, graph designs and pattern tiles changed; the colors of graphic styles and brushes are not counted}",
has_doc,
super::colormgmt::convert_mode
),
+10 -3
View File
@@ -794,11 +794,14 @@ fn new_document_from_state(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let mut doc = Document::clone(chosen.as_ref().unwrap_or(&st.doc));
let (imported_from, import_losses, opened_by) = (st.imported_from.clone(), st.import_losses.clone(), st.opened_by.clone());
if let Some(path) = st.path.as_deref() {
let base = super::links::LinkBase::of(st);
if base.doc.is_some() {
// Files are looked for within the asking caller's roots, else within those of the caller that opened the source.
let roots = crate::file_access::current().or_else(|| opened_by.clone());
crate::file_access::confine(roots.as_ref(), || super::links::with_found_paths(&mut doc, path));
crate::file_access::confine(roots.as_ref(), || super::links::with_found_paths(&mut doc, base));
}
// The copy's links keep their relative paths, which name places from the same file.
let links_from = base.relative.map(str::to_string);
doc.drop_edit_modes();
if doc.pattern_edit.is_some() {
super::patterncmds::finish_edit(&mut doc, &mut vectorcraft_doc::Selection::default())?;
@@ -815,6 +818,7 @@ fn new_document_from_state(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc_mut()?;
st.imported_from = imported_from;
st.import_losses = import_losses;
st.links_from = links_from;
st.opened_by = st.opened_by.take().or(opened_by);
st.mark_unsaved();
Ok(json!({ "index": i, "title": st.title() }))
@@ -930,7 +934,10 @@ fn without_inner(doc: &Document, placed: Vec<(NodeId, Option<Vec<usize>>)>) -> i
/// The direct-selection flag and resolved root nodes for Copy and Cut operations.
fn copy_cut_targets(s: &Session) -> Result<(bool, Vec<NodeId>)> {
let st = s.doc()?;
let in_drawing_tool = matches!(s.tool_id(), "pen" | "curvature");
// The Pen and the Curvature tool leave the anchor they placed or edited selected: Copy takes
// the whole path then (#2122), unless a selection tool Cmd lent them picked the anchors (#6123).
let picked = s.lent_selection.as_ref().is_some_and(|(uid, sel)| *uid == st.uid && *sel == st.selection);
let in_drawing_tool = matches!(s.tool_id(), "pen" | "curvature") && !picked;
let direct = !in_drawing_tool && (!st.selection.anchors.is_empty() || !st.selection.segments.is_empty());
let in_isolated_compound = st.isolation.is_some_and(|iso| st.doc.node(iso).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. })));
let roots = roots_within(&st.doc, st.selection.in_paint_order(&st.doc), |_compound, member| {
+3 -1
View File
@@ -129,7 +129,9 @@ pub(crate) use access::{drive_share_path, share_path, touched};
pub(crate) use access::{may_read, may_write};
pub use commands::specs;
pub use export_names::artboard_file_names;
pub(crate) use export_names::{default_name, merge, unique_file_names, with_black_output, write_or_return};
pub(crate) use export_names::{
artboard_file_names_keeping_dots, default_name, merge, unique_file_names_keeping_dots, with_black_output, write_or_return,
};
use export_names::{distinct_names_by, write_encoded};
use formats::{ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, import_name, writable};
pub use formats::{
+29 -2
View File
@@ -5,19 +5,46 @@ use super::{Encoded, EngineError, Result, Session, Value, json};
/// `-`, `Artboard-N` when unnamed, and `-2`, `-3`… after a name already used (compared without
/// case: `Icon` and `icon` are one file on most desktop file systems).
pub fn artboard_file_names(doc: &vectorcraft_doc::Document, boards: &[usize]) -> Vec<String> {
unique_file_names(boards.iter().map(|&b| (doc.artboards.get(b).map_or("", |a| a.name.as_str()), format!("Artboard-{}", b + 1))))
unique_file_names(artboard_names(doc, boards))
}
/// [`artboard_file_names`] with the dots of a name kept (`example.artboard`), for Export for Screens,
/// which puts every stem through `safe_file_name` after this (trailing periods, device names).
pub(crate) fn artboard_file_names_keeping_dots(doc: &vectorcraft_doc::Document, boards: &[usize]) -> Vec<String> {
unique_file_names_keeping_dots(artboard_names(doc, boards))
}
/// Each of `boards`' name, with the file name to use when it has none.
fn artboard_names<'a>(doc: &'a vectorcraft_doc::Document, boards: &'a [usize]) -> impl Iterator<Item = (&'a str, String)> {
boards.iter().map(|&b| (doc.artboards.get(b).map_or("", |a| a.name.as_str()), format!("Artboard-{}", b + 1)))
}
/// File-name parts for `(name, fallback)` pairs (artboards, assets): the name with unsafe
/// characters as `-`, the fallback when it is empty, and `-2`, `-3`… after a name already used
/// (compared without case).
pub(crate) fn unique_file_names<'a>(names: impl IntoIterator<Item = (&'a str, String)>) -> Vec<String> {
fn unique_file_names<'a>(names: impl IntoIterator<Item = (&'a str, String)>) -> Vec<String> {
distinct_names(names.into_iter().map(|(name, fallback)| {
let base: String = name.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '-' }).collect();
if base.is_empty() { fallback } else { base }
}))
}
/// [`unique_file_names`] keeping `.` in a name (an artboard called `example.artboard` exports as
/// `example.artboard.png`, #6143). A name of only dots (`.` and `..` name other folders) takes the
/// fallback, and leading dots are dashes (`.draft` would be a hidden file); the caller still runs
/// each stem through `safe_file_name`, which drops trailing periods and guards Windows device names.
pub(crate) fn unique_file_names_keeping_dots<'a>(names: impl IntoIterator<Item = (&'a str, String)>) -> Vec<String> {
distinct_names(names.into_iter().map(|(name, fallback)| {
let base: String = name.chars().map(|c| if c.is_alphanumeric() || matches!(c, '-' | '_' | '.') { c } else { '-' }).collect();
if base.trim_matches('.').is_empty() {
return fallback;
}
// A leading dot would hide the file or folder on Unix-like systems: it is a dash, as before.
let rest = base.trim_start_matches('.');
format!("{}{rest}", "-".repeat(base.len() - rest.len()))
}))
}
/// `names` with `-2`, `-3`… after a name already used (compared without case).
pub(super) fn distinct_names(names: impl IntoIterator<Item = String>) -> Vec<String> {
distinct_names_by(names, str::to_lowercase)
+7 -3
View File
@@ -564,13 +564,14 @@ impl ReadFile {
/// The first half of [`open_bytes_with`], which needs no session (an app may do it on another
/// thread): read and decode the file, read its linked files and lay out its type → what
/// [`open_read`] opens. The linked files are looked for from `path`, where the file was read from,
/// [`open_read`] opens. Type in the file's composite fonts is measured again once the document
/// opens and its composite fonts are registered. The linked files are looked for from `path`, where the file was read from,
/// and modified ones are read again when `update_links` (Preferences › File Handling › Update
/// Links set to Automatically; see [`crate::cmd::links::resolve`]).
pub fn read_for_open(name: &str, bytes: &[u8], path: Option<String>, p: &Value, update_links: bool, low_res_proxy_eps: bool) -> Result<ReadFile> {
let opts = LoadOptions::from_params("document.open", p)?;
let mut loaded = load_with(name, bytes, &opts)?;
let links = crate::cmd::links::resolve(&mut loaded.doc, path.as_deref(), update_links, low_res_proxy_eps);
let links = crate::cmd::links::resolve(&mut loaded.doc, crate::cmd::links::LinkBase::file(path.as_deref()), update_links, low_res_proxy_eps);
Session::refresh_text_bounds(&mut loaded.doc);
Ok(ReadFile { loaded, opts, path, links })
}
@@ -590,7 +591,8 @@ pub fn open_template(s: &mut Session, name: &str, bytes: &[u8], path: Option<&st
"only this Affinity file's embedded preview could be read: use File › Open to see it with its warning, or export SVG or PDF from Affinity first",
));
}
let links = crate::cmd::links::resolve(&mut loaded.doc, path, s.prefs.update_links == "automatically", s.prefs.low_res_proxy_eps);
let base = crate::cmd::links::LinkBase::file(path);
let links = crate::cmd::links::resolve(&mut loaded.doc, base, s.prefs.update_links == "automatically", s.prefs.low_res_proxy_eps);
open_loaded(s, loaded, links, path.map(str::to_string), &LoadOptions::default(), true)
}
@@ -629,6 +631,7 @@ fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option<St
// writes .ai that way).
let partial = format.id == "pdf" && opts.is_partial();
let lossy_ai = format.id == "ai" && !restored;
let read_from = path.clone();
let path = path.filter(|_| !template && !partial && !lossy_ai && SAVE_FORMATS.contains(&format.id));
let saves_back = path.is_some();
let converted = saves_back && converted && s.prefs.append_converted;
@@ -648,6 +651,7 @@ fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option<St
st.converted = converted;
st.imported_from = source_path;
st.import_losses = losses;
st.links_from = read_from;
let opened = &s.documents()[index];
let (title, file_info_title) = (opened.title(), opened.doc.title.clone());
Ok(super::merge(
+2 -1
View File
@@ -777,7 +777,8 @@ fn job_for_stamped(s: &Session, st: &DocState, plan: SavePlan, stamped: Option<i
stamp_doc_dates(Arc::make_mut(&mut snapshot), at);
}
// A native file records its links' paths relative to where it is written.
let relative = plan.path.as_deref().filter(|_| is_native(plan.format)).and_then(|p| crate::cmd::links::with_relative_paths(&own, p));
let base = crate::cmd::links::LinkBase::of(st);
let relative = plan.path.as_deref().filter(|_| is_native(plan.format)).and_then(|p| crate::cmd::links::with_relative_paths(&own, base, p));
let doc = relative.map_or(own, Arc::new);
let mut params = plan.options.clone();
if is_svg(plan.format) {
+4 -4
View File
@@ -21,8 +21,8 @@ use super::encode::single_artboard;
use super::export::{export_crop, isolated, selected_artwork, without_artboards};
use super::naming::{self, NameParts};
use super::{
ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names, create_dir, distinct_names_by, encode_with_warnings, merge, unique_file_names,
with_black_output, writable, write_file,
ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names_keeping_dots, create_dir, distinct_names_by, encode_with_warnings, merge,
unique_file_names_keeping_dots, with_black_output, writable, write_file,
};
const C: &str = "document.exportForScreens";
@@ -1184,7 +1184,7 @@ fn items(
// the item folders are (`fold`).
let safe = |stems: Vec<String>| distinct_names_by(stems.iter().map(|s| safe_file_name(s)), fold);
let named = |pieces: Vec<(String, Arc<Document>, Option<usize>, Rect)>, fallback: &str| -> Vec<Item> {
let stems = safe(unique_file_names(pieces.iter().map(|(n, ..)| (n.as_str(), fallback.to_string()))));
let stems = safe(unique_file_names_keeping_dots(pieces.iter().map(|(n, ..)| (n.as_str(), fallback.to_string()))));
pieces
.into_iter()
.zip(stems)
@@ -1261,7 +1261,7 @@ fn items(
if boards.is_empty() {
return Err(bad(C, "the document has no artboard"));
}
let stems = safe(artboard_file_names(doc, &boards));
let stems = safe(artboard_file_names_keeping_dots(doc, &boards));
Ok(boards
.into_iter()
.zip(stems)
@@ -720,7 +720,7 @@ fn a_file_whose_pdf_part_cant_be_read_outlines_its_type_when_asked() {
assert!(paths > 0, "the outlines are there: {r}");
}
/// An image too large to read from the editing data of a file whose PDF part can't be read is
/// An image whose data is cut short in the editing data of a file whose PDF part can't be read is
/// left out, and both notes say so.
#[test]
fn a_file_whose_pdf_part_cant_be_read_notes_an_image_left_out() {
@@ -729,7 +729,7 @@ fn a_file_whose_pdf_part_cant_be_read_notes_an_image_left_out() {
let mut s = Session::new();
let e = open(&mut s, name, &file, json!({"editingData": false})).unwrap_err().to_string();
let r = open(&mut s, name, &file, json!({})).unwrap();
assert_eq!(r["warnings"], json!([unread_pdf_part(&e), "an image too large to read from the editing data was left out"]), "{r}");
assert_eq!(r["warnings"], json!([unread_pdf_part(&e), "an image whose data is cut short was left out"]), "{r}");
}
/// A copied file whose PDF part can't be read pastes the objects of its editing data when asked
+18 -12
View File
@@ -932,25 +932,31 @@ fn gradients_and_patterns_come_back_from_the_editing_copy() {
}
let r = s.execute("document.export", &json!({"format": "ai", "aiVersion": 7})).unwrap();
let legacy = load::load_with("art.ai", &b64(&r), &LoadOptions::default()).unwrap();
// Each gradient the art paints with, by its kind and its stops' places and colors to 5% (version
// 7 writes a gradient stroke as its filled outline, so the objects differ, and a spot color's
// tint through its CMYK equivalent, 2% off here).
// Each gradient the art paints with, by its kind and its stops' places, and its stops' colors
// within 5% (version 7 writes a gradient stroke as its filled outline, so the objects differ),
// except a spot color's tint, which version 7 writes through its CMYK equivalent: 70% of that
// equivalent displays about 7% off the tint of the spot color.
let gradients = |doc: &vectorcraft_doc::Document| {
let mut all = std::collections::BTreeSet::new();
let mut all = std::collections::BTreeMap::new();
doc.walk(|node| {
for it in node.appearance.items.iter() {
if let vectorcraft_color::Paint::Gradient(g) = it.paint() {
let stops: Vec<String> = g
.gradient
.stops
.iter()
.map(|s| format!("{:.3} {:?}", s.offset, s.color.to_rgb().map(|v| (v * 20.0).round() * 5.0)))
.collect();
all.insert(format!("{:?} {stops:?}", g.gradient.kind));
let places: Vec<String> = g.gradient.stops.iter().map(|s| format!("{:.3}", s.offset)).collect();
let colors: Vec<([f32; 3], bool)> =
g.gradient.stops.iter().map(|s| (s.color.to_rgb(), s.swatch.is_some() && s.tint < 1.0)).collect();
all.insert(format!("{:?} {places:?}", g.gradient.kind), colors);
}
}
});
all
};
assert_eq!(gradients(&legacy.doc), gradients(&before));
let (got, want) = (gradients(&legacy.doc), gradients(&before));
assert_eq!(got.keys().collect::<Vec<_>>(), want.keys().collect::<Vec<_>>());
for (key, stops) in &want {
for (i, (rgb, spot)) in stops.iter().enumerate() {
let back = got[key][i].0;
let most = if *spot { 0.1 } else { 0.05 };
assert!((0..3).all(|c| (back[c] - rgb[c]).abs() < most), "{key} stop {i}: {back:?} for {rgb:?}");
}
}
}
@@ -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
+3 -3
View File
@@ -67,7 +67,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Missing Fonts",
[],
None,
"{} → {fonts: [{family, style, status: missing|substitute, resolved: {family, style}}] (sorted by name; at most 1000), count, fontsNextToDocument?} the fonts the active document's type uses, in its layers and its symbols, that aren't available as named: missing (the family is unknown and the fallback family stands in) or substitute (the family is there but not the style; resolved names the stand-in), as text.fonts says. CSS generic families such as sans-serif and names longer than 256 bytes are left out. fontsNextToDocument: the Fonts folder next to the saved document (File › Package writes it), when there is one and a search may start there (not in another app's folders, such as a mail app's attachments)",
"{} → {fonts: [{family, style, status: missing|substitute, resolved: {family, style}}] (sorted by name; at most 1000), count, fontsNextToDocument?} the fonts the active document's type uses, in its layers and its symbols (for type in a composite font, the fonts of the parts its characters are drawn in), that aren't available as named: missing (the family is unknown and the fallback family stands in) or substitute (the family is there but not the style; resolved names the stand-in), as text.fonts says. CSS generic families such as sans-serif and names longer than 256 bytes are left out. fontsNextToDocument: the Fonts folder next to the saved document (File › Package writes it), when there is one and a search may start there (not in another app's folders, such as a mail app's attachments)",
has_doc,
missing
),
@@ -103,8 +103,8 @@ pub struct MissingFont {
pub resolved: Option<(String, String)>,
}
/// The fonts the type of `d` uses, in its layers and its symbols, that aren't available as named
/// (sorted by family and style, each once whatever version its type names; at most 1000). CSS generic families such as `sans-serif`
/// The fonts the type of `d` uses, in its layers and its symbols (a composite font's parts, those
/// its characters are drawn in, in place of its name), that aren't available as named (sorted by family and style, each once whatever version its type names; at most 1000). CSS generic families such as `sans-serif`
/// ([`GENERIC_FAMILIES`]) and names longer than 256 bytes are left out.
pub fn missing_fonts(d: &Document) -> Vec<MissingFont> {
let db = FontDb::global();
+196 -24
View File
@@ -10,8 +10,9 @@ use vectorcraft_color::Paint;
use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind, TextKind, TextObject, TextRun};
use vectorcraft_geom::Rect;
use super::select::on_artboard;
use super::typecmd::refresh_bounds;
use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, str_param};
use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, index, str_param};
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -20,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Fonts in Document",
[],
None,
"{selectionOnly?} (the type in the art of the symbols in scope included: all of the document's, or those the selected instances show) → [{family, style, runs, objects, missing, status: exact|substitute|missing, resolved: {family, style}, missingGlyphs, version?: the installed version the type names (when it names one)}] sorted by name. A font is found by any of its names (ヒラギノ角ゴシック = Hiragino Sans) among the loaded and installed fonts; substitute: the family is there but not the style (resolved names the stand-in); missing: the family is unknown (the fallback family stands in); missingGlyphs: characters of its text the resolved font lacks (drawn by a fallback font)",
"{selectionOnly?} (the type in the art of the symbols in scope included: all of the document's, or those the selected instances show) → [{family, style, runs, objects, missing, status: exact|substitute|missing, resolved: {family, style}, missingGlyphs, version?: the installed version the type names (when it names one)}] sorted by name. A font is found by any of its names (ヒラギノ角ゴシック = Hiragino Sans) among the loaded and installed fonts; substitute: the family is there but not the style (resolved names the stand-in); missing: the family is unknown (the fallback family stands in); missingGlyphs: characters of its text the resolved font lacks (drawn by a fallback font). For a composite font, status is the worst among the parts its characters are drawn in, and missingGlyphs counts the characters their part's font lacks",
has_doc,
fonts
),
@@ -29,7 +30,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Replace Font",
[],
None,
"{from: {family, style?}, to: {family (any of its names; stored as the font's own family name), style?}, selectionOnly?} (style omitted: every style of the family / keep the closest style); the type in the art of the symbols in scope changes too (all of the document's, or those the selected instances show, so every instance of them follows); text that is hidden or locked, in a hidden or locked group or layer (or group of a symbol's art), or on a locked artboard keeps its font; when no run changes (text already set in the new font, or none to change), the document and its undo history stay as they are → {runs: the runs changed, skipped?: the text objects left in the font, warning?: text (some text was left in its font, or the style set isn't available: its type is drawn in the family's closest style)}",
"{from: {family, style?}, to: {family (any of its names; stored as the font's own family name, a composite font's as its own name), style?}, selectionOnly?} (style omitted: every style of the family / keep the closest style); the type in the art of the symbols in scope changes too (all of the document's, or those the selected instances show, so every instance of them follows); text that is hidden or locked, in a hidden or locked group or layer (or group of a symbol's art), or on a locked artboard keeps its font; when no run changes (text already set in the new font, or none to change), the document and its undo history stay as they are → {runs: the runs changed, skipped?: the text objects left in the font, warning?: text (some text was left in its font, or the style set isn't available: its type is drawn in the family's closest style)}",
has_doc,
replace
),
@@ -38,7 +39,7 @@ pub fn specs() -> Vec<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
),
@@ -207,7 +208,18 @@ pub(super) fn font_label((family, style, version): &UsedFont) -> String {
}
}
/// The fonts (family, style, version) of the type in the layers and symbols of `d`.
/// The fonts (family, style, version) run `r` is drawn in: its own, or, set in a composite font,
/// the fonts of the parts its characters fall in ([`vectorcraft_text::FontDb::composite_fonts_for`]).
pub(super) fn run_fonts(r: &TextRun) -> Vec<UsedFont> {
let parts = if r.inline.is_some() { None } else { vectorcraft_text::FontDb::global().composite_fonts_for(&r.style.font_family, &r.text) };
match parts {
Some(parts) => parts.into_iter().map(|(family, style)| (family, style, None)).collect(),
None => vec![font_of(&r.style)],
}
}
/// The fonts (family, style, version) the type in the layers and symbols of `d` is drawn in, a
/// composite font's parts in place of its name ([`run_fonts`]).
pub(super) fn used_fonts(d: &Document) -> BTreeSet<UsedFont> {
art_fonts(d, &[])
}
@@ -218,7 +230,7 @@ pub(super) fn art_fonts(d: &Document, art: &[Node]) -> BTreeSet<UsedFont> {
let mut fonts = BTreeSet::new();
let mut add = |n: &Node| {
if let NodeKind::Text(t) = &n.kind {
fonts.extend(t.runs.iter().map(|r| (r.style.font_family.clone(), r.style.font_style.clone(), r.style.font_version.clone())));
fonts.extend(t.runs.iter().flat_map(run_fonts));
}
};
d.walk(&mut add);
@@ -343,7 +355,7 @@ impl Printed<'_> {
self.overflow.insert(n.id);
}
for r in runs {
self.fonts.insert((r.style.font_family.clone(), r.style.font_style.clone(), r.style.font_version.clone()));
self.fonts.extend(run_fonts(r));
// Inline graphics draw their symbols' art.
if let Some(art) = &r.inline {
self.symbol(&art.symbol);
@@ -545,14 +557,15 @@ fn font_inventory(s: &Session, selected: Option<&[NodeId]>) -> Result<Value> {
let symbols = if selected.is_some() { Some(selected_symbols(s, selected)?.0) } else { None };
let mut texts: Vec<&TextObject> = ids.iter().filter_map(|id| text(d, *id)).collect();
texts.extend(symbol_texts(d, symbols.as_ref()).into_iter().map(|(t, _)| t));
let mut found: BTreeMap<UsedFont, (usize, usize, String)> = BTreeMap::new();
// Each font's runs, objects and the text of its runs.
let mut found: BTreeMap<UsedFont, (usize, usize, Vec<&str>)> = BTreeMap::new();
for t in texts {
let mut seen = BTreeSet::new();
for r in &t.runs {
let font = (r.style.font_family.clone(), r.style.font_style.clone(), r.style.font_version.clone());
let e = found.entry(font.clone()).or_default();
e.0 += 1;
e.2.push_str(&r.text);
e.2.push(&r.text);
if seen.insert(font) {
e.1 += 1;
}
@@ -560,14 +573,27 @@ fn font_inventory(s: &Session, selected: Option<&[NodeId]>) -> Result<Value> {
}
let list: Vec<Value> = found
.into_iter()
.map(|((family, style, version), (runs, objects, chars))| {
.map(|((family, style, version), (runs, objects, texts))| {
// The face the type is set in: the version it names when that one is installed.
let resolved = db.resolve(&family, &style).map(|(f, m)| (db.face_version(&family, &style, version.as_deref()).unwrap_or(f), m));
let status = resolved.as_ref().map(|(_, m)| *m).unwrap_or(vectorcraft_text::FontMatch::Missing);
let mut lacking: Vec<char> = chars.chars().filter(|c| !c.is_whitespace() && !c.is_control()).collect();
lacking.sort_unstable();
lacking.dedup();
lacking.retain(|c| !resolved.as_ref().is_some_and(|(f, _)| f.covers(*c)));
let mut resolved = db.resolve(&family, &style).map(|(f, m)| (db.face_version(&family, &style, version.as_deref()).unwrap_or(f), m));
let (status, lacking) = match db.composite_coverage(&family, &texts) {
// A composite font: each character is drawn by the font of the part it falls in, and
// the row names the face of the first part that isn't there as named.
Some(c) => {
if c.status != vectorcraft_text::FontMatch::Exact {
resolved = c.face.map(|f| (f, c.status));
}
(c.status, c.lacking)
}
None => {
let status = resolved.as_ref().map(|(_, m)| *m).unwrap_or(vectorcraft_text::FontMatch::Missing);
let mut lacking: Vec<char> = texts.concat().chars().filter(|c| !c.is_whitespace() && !c.is_control()).collect();
lacking.sort_unstable();
lacking.dedup();
lacking.retain(|c| !resolved.as_ref().is_some_and(|(f, _)| f.covers(*c)));
(status, lacking)
}
};
let mut row = json!({
"family": family, "style": style, "runs": runs, "objects": objects,
"missing": status == vectorcraft_text::FontMatch::Missing,
@@ -611,10 +637,11 @@ fn replace(s: &mut Session, p: &Value) -> Result<Value> {
let (ff, fs) = font_param(p, "from", C)?;
let (tf, ts) = font_param(p, "to", C)?;
let db = vectorcraft_text::FontDb::global();
// Any name of the family (ヒラギノ角ゴシック) is stored as its own name (Hiragino Sans).
let tf = match db.resolve(&tf, ts.as_deref().unwrap_or("Regular")) {
Some((f, m)) if m != vectorcraft_text::FontMatch::Missing => f.family.clone(),
_ => return Err(bad(C, format!("font family `{tf}` is not available"))),
// Any name of the family (ヒラギノ角ゴシック) is stored as its own name (Hiragino Sans), and a
// composite font as its own name.
let composite = db.composite_font(&tf).is_some();
let Some(tf) = db.stored_family(&tf, ts.as_deref().unwrap_or("Regular")) else {
return Err(bad(C, format!("font family `{tf}` is not available")));
};
let matches = |st: &vectorcraft_doc::CharStyle| {
st.font_family.eq_ignore_ascii_case(&ff) && fs.as_ref().is_none_or(|x| st.font_style.eq_ignore_ascii_case(x))
@@ -625,8 +652,10 @@ fn replace(s: &mut Session, p: &Value) -> Result<Value> {
if !matches(st) {
return None;
}
// An explicit style, else the closest the new family has to the old one.
let style = ts.clone().or_else(|| db.face(&tf, &st.font_style).map(|f| f.style.clone())).unwrap_or_else(|| st.font_style.clone());
// An explicit style, else the closest the new family has to the old one (a composite font's
// parts name their own styles: the run keeps its style).
let closest = || if composite { None } else { db.face(&tf, &st.font_style).map(|f| f.style.clone()) };
let style = ts.clone().or_else(closest).unwrap_or_else(|| st.font_style.clone());
(st.font_family != tf || st.font_style != style || st.font_version.is_some() || !st.font_axes.is_empty()).then_some(style)
};
// Only text the Selection tool can reach changes: not hidden or locked, in a hidden or locked
@@ -673,7 +702,7 @@ fn replace(s: &mut Session, p: &Value) -> Result<Value> {
// Another font: none of the old one's versions or Variable Font settings.
r.style.font_version = None;
r.style.font_axes.clear();
set.insert(font_of(&r.style));
set.extend(run_fonts(r));
changed = true;
n += 1;
}
@@ -689,7 +718,6 @@ fn replace(s: &mut Session, p: &Value) -> Result<Value> {
}
}
edit_symbol_texts(d, symbols.as_ref(), &|t, open| open && changes(t), &mut replace_in);
drop(replace_in);
Ok(n)
})?
};
@@ -714,6 +742,7 @@ fn select_font(s: &mut Session, p: &Value) -> Result<Value> {
let style = str_param(p, "style").map(str::to_string);
let ids = scope(s, false, None)?;
let d = &s.doc()?.doc;
let within = FontScope::read(d, p)?;
// Only text the Selection tool can reach: not hidden or locked, in a hidden or locked group
// or layer, or on a locked artboard.
let editable = d.editable();
@@ -759,11 +788,65 @@ fn select_font(s: &mut Session, p: &Value) -> Result<Value> {
});
}
drop(editable);
let symbols = d.symbol_boxes();
hits.retain(|id| within.holds(d, &symbols, *id));
drop(symbols);
let n = hits.len();
s.select(|_, sel| sel.set(hits))?;
Ok(json!({ "count": n }))
}
/// Where Select Text by Font looks: its `scope`.
enum FontScope {
Document,
/// The artboards' rectangles.
Artboards(Vec<Rect>),
/// The layers and sublayers, with what they hold.
Layers(BTreeSet<NodeId>),
}
impl FontScope {
fn read(d: &Document, p: &Value) -> Result<Self> {
const C: &str = "select.font";
let expected = || bad(C, "`scope` must be \"document\", {artboards: [index, …]} or {layers: [id, …]}");
let scope = match p.get("scope") {
None | Some(Value::Null) => return Ok(Self::Document),
Some(Value::String(v)) if v == "document" => return Ok(Self::Document),
Some(Value::Object(m)) if m.len() == 1 => m,
Some(_) => return Err(expected()),
};
let list = |key: &str| -> Result<Option<&Vec<Value>>> {
match scope.get(key) {
None => Ok(None),
Some(Value::Array(a)) if !a.is_empty() => Ok(Some(a)),
Some(_) => Err(bad(C, format!("`scope.{key}` must be a non-empty array"))),
}
};
if let Some(a) = list("artboards")? {
let rect = |v: &Value| v.as_u64().and_then(|i| d.artboards.get(index(i))).map(|b| b.rect);
return a.iter().map(|v| rect(v).ok_or_else(|| bad(C, format!("no such artboard {v}")))).collect::<Result<_>>().map(Self::Artboards);
}
if let Some(a) = list("layers")? {
let layer = |v: &Value| v.as_u64().map(NodeId).filter(|id| d.node(*id).is_some_and(Node::is_layer));
return a
.iter()
.map(|v| layer(v).ok_or_else(|| bad(C, format!("{v} names no layer or sublayer"))))
.collect::<Result<_>>()
.map(Self::Layers);
}
Err(expected())
}
/// Is object `id` in the scope.
fn holds(&self, d: &Document, symbols: &vectorcraft_doc::SymbolBoxes, id: NodeId) -> bool {
match self {
Self::Document => true,
Self::Artboards(rects) => d.node(id).is_some_and(|n| rects.iter().any(|r| on_artboard(n, symbols, *r))),
Self::Layers(layers) => d.ancestry(id).is_some_and(|a| a.iter().any(|i| layers.contains(i))),
}
}
}
fn font_list(_: &mut Session, p: &Value) -> Result<Value> {
let db = vectorcraft_text::FontDb::global();
match str_param(p, "family") {
@@ -912,6 +995,95 @@ mod tests {
assert!(s.doc().unwrap().selection.objects.is_empty());
}
/// Select Text by Font with an artboard scope takes the text on the artboards, as All on
/// Active Artboard does: overlapping one, or centered on it, a straddling one included.
#[test]
fn select_font_in_artboards() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
s.execute("artboard.new", &json!({"x": 300, "y": 0, "width": 200, "height": 200})).unwrap();
s.execute("artboard.new", &json!({"x": 600, "y": 0, "width": 200, "height": 200})).unwrap();
let text = |s: &mut Session, x: i32| {
NodeId(s.execute("text.create", &json!({"x": x, "y": 50, "text": "Serif", "font": "Source Serif 4"})).unwrap()["id"].as_u64().unwrap())
};
let first = text(&mut s, 10);
let straddling = text(&mut s, 180);
let second = text(&mut s, 350);
let third = text(&mut s, 650);
let _off = text(&mut s, 1000);
let select = |s: &mut Session, scope: Value| {
s.execute("select.font", &json!({"family": "Source Serif 4", "scope": scope})).unwrap();
s.doc().unwrap().selection.objects.clone()
};
assert_eq!(select(&mut s, json!({"artboards": [0]})), [first, straddling]);
assert_eq!(select(&mut s, json!({"artboards": [1, 2]})), [second, third]);
assert_eq!(select(&mut s, json!("document")).len(), 5);
// The same objects All on Active Artboard takes.
s.execute("select.allOnArtboard", &json!({"artboard": 0})).unwrap();
assert_eq!(s.doc().unwrap().selection.objects, [first, straddling]);
// Hidden text stays out.
s.execute("select.set", &json!({"ids": [third.0]})).unwrap();
s.execute("object.hide", &json!({})).unwrap();
assert_eq!(select(&mut s, json!({"artboards": [1, 2]})), [second]);
for bad in [json!({"artboards": [3]}), json!({"artboards": []}), json!({"artboards": ["x"]}), json!({"pages": [0]}), json!("all"), json!(7)] {
assert!(s.execute("select.font", &json!({"family": "Source Serif 4", "scope": bad})).is_err(), "{bad}");
}
}
/// Select Text by Font with a layer scope takes the text in the layers, their sublayers
/// included, and still leaves out hidden and locked text.
#[test]
fn select_font_in_layers() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
let text = |s: &mut Session, y: i32| {
NodeId(s.execute("text.create", &json!({"x": 10, "y": y, "text": "Serif", "font": "Source Serif 4"})).unwrap()["id"].as_u64().unwrap())
};
let first_layer = s.doc().unwrap().doc.layers[0].id;
let a = text(&mut s, 20);
let second_layer = s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap();
let b = text(&mut s, 40);
let sub = s.execute("layer.newSublayer", &json!({"parent": second_layer})).unwrap()["id"].as_u64().unwrap();
s.execute("layer.setCurrent", &json!({"id": sub})).unwrap();
let in_sub = text(&mut s, 60);
let locked = text(&mut s, 80);
s.execute("select.set", &json!({"ids": [locked.0]})).unwrap();
s.execute("object.lock", &json!({})).unwrap();
let select = |s: &mut Session, layers: Value| {
s.execute("select.font", &json!({"family": "Source Serif 4", "scope": {"layers": layers}})).unwrap();
let mut v = s.doc().unwrap().selection.objects.clone();
v.sort();
v
};
let d = &s.doc().unwrap().doc;
assert_eq!(d.ancestry(in_sub).unwrap()[1], NodeId(sub), "the text is in the sublayer");
assert_eq!(select(&mut s, json!([first_layer.0])), [a]);
assert_eq!(select(&mut s, json!([second_layer])), [b, in_sub]);
assert_eq!(select(&mut s, json!([sub])), [in_sub]);
assert_eq!(select(&mut s, json!([first_layer.0, sub])), [a, in_sub]);
// Not a layer: an error, the selection as it was.
assert!(s.execute("select.font", &json!({"family": "Source Serif 4", "scope": {"layers": [a.0]}})).is_err());
assert!(s.execute("select.font", &json!({"family": "Source Serif 4", "scope": {"layers": [u64::MAX]}})).is_err());
assert_eq!(s.doc().unwrap().selection.objects.len(), 2);
}
/// A symbol instance whose art uses the font is selected when the instance is in scope.
#[test]
fn select_font_scope_takes_symbol_instances_in_it() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
s.execute("artboard.new", &json!({"x": 300, "y": 0, "width": 200, "height": 200})).unwrap();
let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Serif", "font": "Source Serif 4"})).unwrap()["id"].clone();
s.execute("symbol.new", &json!({"name": "Mark", "ids": [id]})).unwrap();
let instance = s.doc().unwrap().selection.objects[0];
let select = |s: &mut Session, i: u64| {
s.execute("select.font", &json!({"family": "Source Serif 4", "scope": {"artboards": [i]}})).unwrap()["count"].clone()
};
assert_eq!(select(&mut s, 0), 1);
assert_eq!(s.doc().unwrap().selection.objects, [instance]);
assert_eq!(select(&mut s, 1), 0);
}
/// Replace Font leaves hidden and locked text in its font, and text inside a hidden or locked
/// group or layer, and says so. Text on a visible, unlocked template layer is changed.
#[test]
+2 -2
View File
@@ -601,7 +601,7 @@ fn set_current(s: &mut Session, p: &Value) -> Result<Value> {
fn highlight(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "layer.highlight";
let mut ids = ids_param(p, "ids").ok_or_else(|| bad(C, "missing ids"))?;
let mut ids = ids_param(p, "ids", C)?.ok_or_else(|| bad(C, "missing ids"))?;
ids.truncate(MAX_ROWS);
let mode = str_param(p, "mode").unwrap_or("set");
let st = s.doc_mut()?;
@@ -740,7 +740,7 @@ enum Place {
/// Drag rows in the Layers panel.
fn move_rows(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "layer.move";
let mut ids = ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])).ok_or_else(|| bad(C, "missing ids"))?;
let mut ids = ids_param(p, "ids", C)?.or_else(|| id_param(p, "id").map(|i| vec![i])).ok_or_else(|| bad(C, "missing ids"))?;
ids.truncate(MAX_ROWS);
let target = id_param(p, "target").ok_or_else(|| bad(C, "missing target"))?;
let place = match str_param(p, "place").unwrap_or("inside") {
+6 -6
View File
@@ -966,7 +966,7 @@ enum Item {
/// The selected objects (or `ids`) as a library graphic (its id and name set by the caller).
fn graphic_of(s: &Session, p: &Value, cmd: &str) -> Result<LibraryGraphic> {
let st = s.doc()?;
let ids = match ids_param(p, "ids") {
let ids = match ids_param(p, "ids", cmd)? {
Some(ids) => ids,
None => st.selection.in_paint_order(&st.doc),
};
@@ -1006,7 +1006,7 @@ fn color_of(s: &Session, p: &Value, stroke: bool, cmd: &str) -> Result<NewColor>
let given = NewColor { name: color.to_hex(), color, spot: None, tint: 1.0 };
return Ok(spot_color(s, given, swatch, tint));
}
match selected_paint(s, p, stroke)? {
match selected_paint(s, p, stroke, cmd)? {
Paint::Solid { color, swatch, tint } => {
let swatch = swatch.filter(|n| !n.is_empty());
let name = swatch.clone().unwrap_or_else(|| color.to_hex());
@@ -1029,9 +1029,9 @@ fn spot_color(s: &Session, c: NewColor, swatch: Option<String>, tint: f32) -> Ne
/// The first selected object's (or `ids`') fill (`stroke`: stroke) paint (type's: its first
/// character's, as the proxy shows it), else the default one.
fn selected_paint(s: &Session, p: &Value, stroke: bool) -> Result<Paint> {
fn selected_paint(s: &Session, p: &Value, stroke: bool, cmd: &str) -> Result<Paint> {
let st = s.doc()?;
let ids = match ids_param(p, "ids") {
let ids = match ids_param(p, "ids", cmd)? {
Some(ids) => ids,
None => st.selection.objects.clone(),
};
@@ -1071,7 +1071,7 @@ fn gradient_of(s: &Session, p: &Value, stroke: bool, cmd: &str) -> Result<Librar
let sw = s.doc()?.doc.swatch(name).filter(|w| matches!(w.paint, Paint::Gradient(_)));
(Some(name.to_string()), sw.ok_or_else(|| bad(cmd, format!("no gradient swatch `{name}`")))?.paint.clone())
}
None => (None, selected_paint(s, p, stroke)?),
None => (None, selected_paint(s, p, stroke, cmd)?),
};
let Paint::Gradient(mut g) = paint else {
return Err(bad(cmd, if stroke { "the selection has no gradient stroke" } else { "the selection has no gradient fill" }));
@@ -1094,7 +1094,7 @@ fn pattern_of(s: &Session, p: &Value, stroke: bool, cmd: &str) -> Result<Library
_ if d.pattern(name).is_some() => name.to_string(),
_ => return Err(bad(cmd, format!("no pattern swatch `{name}`"))),
},
None => match selected_paint(s, p, stroke)? {
None => match selected_paint(s, p, stroke, cmd)? {
Paint::Pattern { pattern, .. } => pattern,
_ => return Err(bad(cmd, if stroke { "the selection has no pattern stroke" } else { "the selection has no pattern fill" })),
},
+153 -65
View File
@@ -23,7 +23,7 @@
use std::borrow::Cow;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Component, Path};
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
use serde_json::{Value, json};
@@ -219,6 +219,30 @@ struct Found {
modified: Option<u64>,
/// Read when telling whether it changed needed them.
bytes: Option<Vec<u8>>,
/// `path` relative to the folder of [`LinkBase::relative`], when [`locate`] had that file.
relative: Option<Option<String>>,
}
/// The files a document's links are looked for from: `doc`, whose folder a link's path is followed
/// from and its file is looked for by name in, and `relative`, the file its links' stored relative
/// paths name places from (the file the document was read from).
#[derive(Clone, Copy, Debug, Default)]
pub struct LinkBase<'a> {
pub doc: Option<&'a str>,
pub relative: Option<&'a str>,
}
impl<'a> LinkBase<'a> {
/// Both the file at `path`.
pub fn file(path: Option<&'a str>) -> Self {
Self { doc: path, relative: path }
}
/// The open document `st`'s. One with no file of its own looks beside the file it was read from.
pub(crate) fn of(st: &'a crate::DocState) -> Self {
let (path, from) = (st.path.as_deref(), st.links_from.as_deref());
Self { doc: path.or(from), relative: from.or(path) }
}
}
impl Found {
@@ -237,7 +261,17 @@ impl Found {
/// [`Self::link`] with the bytes' hash already taken.
fn link_hashed(&self, hash: String, base: &LinkInfo) -> LinkInfo {
LinkInfo { path: self.path.clone(), size: Some(self.size), modified: self.modified, hash: Some(hash), ..base.clone() }
LinkInfo { size: Some(self.size), modified: self.modified, hash: Some(hash), ..self.moved(base) }
}
/// `base` pointing at this file. A file found away from `base`'s path takes its relative path
/// from where it was found.
fn moved(&self, base: &LinkInfo) -> LinkInfo {
let relative = match &self.relative {
Some(found) if self.path != base.path => found.clone(),
_ => base.relative.clone(),
};
LinkInfo { path: self.path.clone(), relative, ..base.clone() }
}
}
@@ -255,15 +289,41 @@ pub(crate) fn link_info(path: &str, bytes: &[u8]) -> LinkInfo {
/// The file at `path` when there is one.
fn found_at(path: String) -> Option<Found> {
let (size, modified) = file_stamp(&path)?;
Some(Found { path, size, modified, bytes: None })
Some(Found { path, size, modified, bytes: None, relative: None })
}
/// Where the file of `link` (stored in the document saved at `doc_path`) is: at its path, else at
/// its relative path or by name in the document's folder. Only paths
/// [`fileio::document_path`] follows are looked at.
fn locate(link: &LinkInfo, doc_path: Option<&str>) -> Option<Found> {
let stored = [Some(link.path.as_str()), link.relative.as_deref(), Some(link.name())];
stored.into_iter().flatten().filter_map(|p| fileio::document_path(p, doc_path).ok()).find_map(found_at)
/// Where the file of `link` (of a document looking from `base`) is: at its path, else at its
/// relative path from the folder of [`LinkBase::relative`], then from the document's folder, else
/// by name in the document's folder, then in that of [`LinkBase::relative`]. Only paths
/// [`fileio::document_path`] follows from the document's own file are looked at.
fn locate(link: &LinkInfo, base: LinkBase) -> Option<Found> {
let (relative, other) = (link.relative.as_deref(), base.doc != base.relative);
let stored = [
(Some(link.path.as_str()), base.doc),
(relative, base.relative),
(relative.filter(|_| other), base.doc),
(Some(link.name()), base.doc),
(other.then_some(link.name()), base.relative),
];
let mut f = stored.into_iter().filter_map(|(p, from)| follow_from(p?, from, base.doc, fileio::document_path)).find_map(found_at)?;
f.relative = base.relative.map(|from| {
let from = absolute_path(from);
Path::new(&from).parent().and_then(|dir| relative_path(Path::new(&f.path), dir))
});
Some(f)
}
/// The place `stored` names from the file `from`, as `resolve` ([`fileio::document_path`]) follows
/// it, when it also follows that place from the document's own file `doc`: a network share the
/// document is no longer on is refused.
pub(crate) fn follow_from(
stored: &str,
from: Option<&str>,
doc: Option<&str>,
resolve: impl Fn(&str, Option<&str>) -> std::result::Result<String, craft_docpath::Refused>,
) -> Option<String> {
let path = resolve(stored, from).ok()?;
if from == doc { Some(path) } else { resolve(&path, doc).ok() }
}
/// Why the path of `link` (stored in the document saved at `doc_path`) isn't followed, when it
@@ -272,10 +332,10 @@ fn refused(link: &LinkInfo, doc_path: Option<&str>) -> Option<craft_docpath::Ref
fileio::document_path(&link.path, doc_path).err().filter(|r| *r != craft_docpath::Refused::Invalid)
}
/// Where the file of group `g` (of the document saved at `doc_path`) is and whether it still has
/// Where the file of group `g` (of a document looking from `base`) is and whether it still has
/// the content shown. Its bytes are read only when its size or modification time changed.
fn probe(g: &Group, doc_path: Option<&str>) -> (Status, Option<Found>) {
let Some(mut f) = locate(&g.link, doc_path) else { return (Status::Missing, None) };
fn probe(g: &Group, base: LinkBase) -> (Status, Option<Found>) {
let Some(mut f) = locate(&g.link, base) else { return (Status::Missing, None) };
let l = &g.link;
if l.size == Some(f.size) && l.modified.is_some() && l.modified == f.modified {
return (Status::Ok, Some(f));
@@ -328,9 +388,9 @@ pub(crate) struct LinkedFile {
pub bytes: Option<Vec<u8>>,
}
/// Every file the linked images of `d` (saved at `doc_path`) show, in the layers, symbols and
/// Every file the linked images of `d` (looking from `base`) show, in the layers, symbols and
/// patterns, once each.
pub(crate) fn linked_files(d: &Document, doc_path: Option<&str>) -> Vec<LinkedFile> {
pub(crate) fn linked_files(d: &Document, base: LinkBase) -> Vec<LinkedFile> {
let mut seen = std::collections::BTreeSet::new();
groups(d, None)
.into_iter()
@@ -338,7 +398,7 @@ pub(crate) fn linked_files(d: &Document, doc_path: Option<&str>) -> Vec<LinkedFi
.map(|g| {
let named = fileio::format_for_name(g.link.name()).or_else(|| fileio::readable_format_for_name(g.link.name()));
let own = |b: &&ImageBlob| !b.is_proxy() && named.is_some_and(|f| f.mime == b.mime);
let found = locate(&g.link, doc_path);
let found = locate(&g.link, base);
let found_path = found.as_ref().map(|f| f.path.clone());
let bytes = found.and_then(|mut f| f.read().ok()).or_else(|| d.images.get(&g.key).filter(own).map(|b| b.bytes.to_vec()));
LinkedFile { path: g.link.path.clone(), name: g.link.name().to_string(), found_path, bytes }
@@ -348,7 +408,7 @@ pub(crate) fn linked_files(d: &Document, doc_path: Option<&str>) -> Vec<LinkedFi
/// `d` for output: linked raster images that still show only their saved preview read their files
/// again (Preferences › Use Low Resolution Proxy for Linked EPS keeps the preview on screen only).
pub(crate) fn full_linked_images<'a>(d: &'a Document, doc_path: Option<&str>) -> (Cow<'a, Document>, Vec<String>) {
pub(crate) fn full_linked_images<'a>(d: &'a Document, base: LinkBase) -> (Cow<'a, Document>, Vec<String>) {
let mut keys = BTreeSet::new();
d.visit_images(|_, im| {
if im.link.is_some() && d.images.get(&im.key).is_some_and(ImageBlob::is_proxy) {
@@ -363,7 +423,7 @@ pub(crate) fn full_linked_images<'a>(d: &'a Document, doc_path: Option<&str>) ->
let all = groups(d, None);
for key in keys {
let Some(g) = all.iter().find(|g| g.document.is_none() && g.key == key) else { continue };
let Some(mut f) = locate(&g.link, doc_path) else {
let Some(mut f) = locate(&g.link, base) else {
previews.insert(key);
continue;
};
@@ -398,7 +458,7 @@ pub(crate) fn full_linked_images<'a>(d: &'a Document, doc_path: Option<&str>) ->
pub(crate) fn files_to_find(st: &crate::DocState, ids: Option<&[NodeId]>) -> Vec<(LinkInfo, Vec<NodeId>)> {
groups(&st.doc, ids)
.into_iter()
.filter(|g| !g.ids.is_empty() && (ids.is_some() || probe(g, st.path.as_deref()).0 == Status::Missing))
.filter(|g| !g.ids.is_empty() && (ids.is_some() || probe(g, LinkBase::of(st)).0 == Status::Missing))
.map(|g| (g.link, g.ids))
.collect()
}
@@ -847,13 +907,13 @@ impl Resolved {
}
}
/// The linked files of document `d`, just read from `doc_path`: those found unchanged are read
/// again (their images had the preview), modified ones are read again when `update`, links found
/// away from their path take the new path. A link nothing is known of whose image, or placed
/// The linked files of document `d`, just read and looking from `base`: those found unchanged are
/// read again (their images had the preview), modified ones are read again when `update`, links
/// found away from their path take the new path. A link nothing is known of whose image, or placed
/// document, shows a preview (a file another app wrote kept only that) was never read: its file is
/// read now, into the object's bounds, which isn't a change to report. A placed document whose
/// file's bytes are gone isn't showing a preview. Nothing here is an undo step.
pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_proxy_eps: bool) -> Resolved {
pub fn resolve(d: &mut Document, base: LinkBase, update: bool, low_res_proxy_eps: bool) -> Resolved {
let mut out = Resolved::default();
// The image (with its preview) and hash of each file read for a preview here, by path: images
// of other keys linking to the same file share them, rather than each reading, decoding and
@@ -863,14 +923,14 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_p
// away from its path or read again.
let mut moved: Vec<(String, String, bool, LinkInfo)> = vec![];
for g in groups(d, None) {
let row = rows(std::iter::once(&g), doc_path);
let row = rows(std::iter::once(&g), base.doc);
let shows_preview = match g.document {
None => previewing(d, &g.key),
Some(_) => d.images.get(&g.key).is_some_and(ImageBlob::is_proxy),
};
let unread = g.link.hash.is_none() && g.link.size.is_none() && shows_preview;
let eps_preview = keep_eps_preview(low_res_proxy_eps, &g, d);
match probe(&g, doc_path) {
match probe(&g, base) {
(Status::Missing, _) => out.missing.extend(row),
(Status::Modified, Some(mut f)) if update || unread => {
let force = unread.then_some(Preserve::Bounds);
@@ -894,7 +954,7 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_p
None => {
let link = match f.bytes.take() {
Some(b) => f.link(&b, &g.link),
None => LinkInfo { path: f.path.clone(), ..g.link.clone() },
None => f.moved(&g.link),
};
if link != g.link {
set_links(d, &g.ids, &link);
@@ -932,7 +992,7 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_p
// Found away from its path, or read again: the link follows.
let link = match &bytes {
Some(b) => f.link(b, &g.link),
None => LinkInfo { path: f.path.clone(), ..g.link.clone() },
None => f.moved(&g.link),
};
if link != g.link {
set_links(d, &g.ids, &link);
@@ -1035,42 +1095,68 @@ fn relative_path(target: &Path, base: &Path) -> Option<String> {
Some(ups.chain(rest).collect::<Vec<std::borrow::Cow<str>>>().join("/"))
}
/// `d` as saved to `dest`: the links' paths relative to its folder written in; `None` when they
/// are already. Links in the layers, symbols, graph designs, patterns and opacity masks are all set.
pub fn with_relative_paths(d: &Document, dest: &str) -> Option<Document> {
let dest = absolute_path(dest);
let dir = Path::new(&dest).parent()?;
let relative = |l: &LinkInfo| relative_path(Path::new(&l.path), dir);
let outdated = |l: &LinkInfo| relative(l) != l.relative;
let mut changed = false;
d.visit_images(|_, im| changed |= im.link.as_ref().is_some_and(outdated));
d.visit_placed(|_, p| changed |= outdated(&p.link));
if !changed {
return None;
/// The file `link` (of a document looking from `base`) is written relative to: where [`locate`]
/// finds it, else the place its relative path names from the folder of [`LinkBase::relative`],
/// else its path.
fn link_target(link: &LinkInfo, base: LinkBase) -> PathBuf {
if let Some(f) = locate(link, base) {
return PathBuf::from(f.path);
}
match (base.relative, link.relative.as_deref()) {
(Some(_), Some(rel)) => fileio::document_path(rel, base.relative).map_or_else(|_| PathBuf::from(&link.path), PathBuf::from),
_ => PathBuf::from(&link.path),
}
let mut d = d.clone();
d.update_links_where(outdated, |l| l.relative = relative(l));
Some(d)
}
/// Sets each link of `d`, a copy of the document saved at `doc_path` that has no file of its own,
/// to the path where that document finds its file, so the copy finds the same files.
/// Links in the layers, symbols, graph designs, patterns and opacity masks are all set.
pub(crate) fn with_found_paths(d: &mut Document, doc_path: &str) {
// The new path of each link by what `locate` reads of it: its path, relative path and name.
let mut found: BTreeMap<(String, Option<String>), Option<String>> = BTreeMap::new();
/// `d` (looking from `base`) as saved to `dest`: the links' paths relative to its folder written
/// in; `None` when they are already. A link whose file is not at its path is written relative to
/// where its file is found, else to the place its relative path names. Links in the layers,
/// symbols, graph designs, patterns and opacity masks are all set.
pub fn with_relative_paths(d: &Document, base: LinkBase, dest: &str) -> Option<Document> {
let dest = absolute_path(dest);
let dir = Path::new(&dest).parent()?;
// The relative path of each link by its path and stored relative path, each file looked at once.
let mut wanted: BTreeMap<(String, Option<String>), Option<String>> = BTreeMap::new();
let mut look = |l: &LinkInfo| {
found.entry((l.path.clone(), l.relative.clone())).or_insert_with(|| locate(l, Some(doc_path)).map(|f| f.path));
wanted.entry((l.path.clone(), l.relative.clone())).or_insert_with(|| relative_path(&link_target(l, base), dir));
};
d.visit_images(|_, im| im.link.iter().for_each(&mut look));
d.visit_placed(|_, p| look(&p.link));
found.retain(|(path, _), f| f.as_ref().is_some_and(|f| f != path));
wanted.retain(|(_, stored), rel| rel != stored);
if wanted.is_empty() {
return None;
}
let key = |l: &LinkInfo| (l.path.clone(), l.relative.clone());
let mut d = d.clone();
d.update_links_where(|l| wanted.contains_key(&key(l)), |l| l.relative = wanted.get(&key(l)).cloned().flatten());
Some(d)
}
/// Sets each link of `d`, a copy of a document looking from `base`, to the path where that
/// document finds its file, and its relative path to it as that document's links have them, so
/// the copy finds the same files. Links in the layers, symbols, graph designs, patterns and opacity
/// masks are all set.
pub(crate) fn with_found_paths(d: &mut Document, base: LinkBase) {
// The new path and relative path of each link by what `locate` reads of it: its path, relative
// path and name.
type PathAndRelative = (String, Option<String>);
let mut found: BTreeMap<PathAndRelative, Option<PathAndRelative>> = BTreeMap::new();
let mut look = |l: &LinkInfo| {
found.entry((l.path.clone(), l.relative.clone())).or_insert_with(|| {
let moved = locate(l, base)?.moved(l);
Some((moved.path, moved.relative))
});
};
d.visit_images(|_, im| im.link.iter().for_each(&mut look));
d.visit_placed(|_, p| look(&p.link));
found.retain(|(path, _), f| f.as_ref().is_some_and(|(f, _)| f != path));
if found.is_empty() {
return;
}
d.update_links(|l| {
if let Some(Some(f)) = found.get(&(l.path.clone(), l.relative.clone())) {
l.path = f.clone();
if let Some(Some((path, relative))) = found.get(&(l.path.clone(), l.relative.clone())) {
l.path = path.clone();
l.relative = relative.clone();
}
});
}
@@ -1079,15 +1165,15 @@ pub(crate) fn with_found_paths(d: &mut Document, doc_path: &str) {
fn check(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let ids = ids_param(p, "ids");
let ids = ids_param(p, "ids", "links.check")?;
let (mut missing, mut modified) = (0, 0);
let mut links = vec![];
for g in groups(&st.doc, ids.as_deref()).iter().filter(|g| !g.ids.is_empty()) {
let (status, found) = probe(g, st.path.as_deref());
let (status, found) = probe(g, LinkBase::of(st));
missing += usize::from(status == Status::Missing);
modified += usize::from(status == Status::Modified);
let mut row = json!({ "name": g.link.name(), "path": g.link.path, "status": status.as_str(), "ids": ids_json(&g.ids) });
note_refused(&mut row, &g.link, st.path.as_deref());
note_refused(&mut row, &g.link, LinkBase::of(st).doc);
if let Some(f) = found.filter(|f| f.path != g.link.path) {
row["found"] = json!(f.path);
}
@@ -1100,7 +1186,7 @@ fn check(s: &mut Session, p: &Value) -> Result<Value> {
}
fn update(s: &mut Session, p: &Value) -> Result<Value> {
update_where(s, ids_param(p, "ids"), None)
update_where(s, ids_param(p, "ids", "links.update")?, None)
}
/// Update Links in the active document of `s` for objects `ids` (default: the linked files that
@@ -1112,7 +1198,7 @@ fn update_where(s: &mut Session, ids: Option<Vec<NodeId>>, path: Option<&str>) -
let (mut reads, mut missing) = (vec![], vec![]);
let asked = ids.is_some() || path.is_some();
for g in groups(&st.doc, ids.as_deref()).into_iter().filter(|g| path.is_none_or(|p| g.document.is_some() && g.link.path == p)) {
match probe(&g, st.path.as_deref()) {
match probe(&g, LinkBase::of(st)) {
(Status::Missing, _) | (_, None) => missing.extend(ids_json(&g.ids)),
// Unchanged and showing the file: nothing to read unless asked for.
(Status::Ok, Some(_)) if !asked && !stale(&st.doc, &g) => {}
@@ -1225,14 +1311,14 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
_ => None,
})
};
let mut ids: Vec<NodeId> = match (&each, ids_param(p, "ids")) {
let mut ids: Vec<NodeId> = match (&each, ids_param(p, "ids", C)?) {
(Some(each), _) => each.iter().flat_map(|(ids, _)| ids.iter().copied()).collect(),
(None, Some(ids)) => ids,
(None, None) => {
let selected: Vec<NodeId> = st.selection.objects.iter().copied().filter(|id| image(id).is_some()).collect();
match folder_param {
Some(_) if selected.is_empty() => {
groups(&st.doc, None).into_iter().filter(|g| probe(g, st.path.as_deref()).0 == Status::Missing).flat_map(|g| g.ids).collect()
groups(&st.doc, None).into_iter().filter(|g| probe(g, LinkBase::of(st)).0 == Status::Missing).flat_map(|g| g.ids).collect()
}
_ => selected,
}
@@ -1368,7 +1454,7 @@ fn missing_in_folder(
let Some(dir) = files.first().and_then(|(_, p)| Path::new(p).parent()).map(Path::to_path_buf) else { return };
for g in groups(&st.doc, None).into_iter().filter(|g| !(images_only && g.document.is_some())) {
let rest: Vec<NodeId> = g.ids.iter().copied().filter(|id| !given.contains(id)).collect();
if rest.is_empty() || probe(&g, st.path.as_deref()).0 != Status::Missing {
if rest.is_empty() || probe(&g, LinkBase::of(st)).0 != Status::Missing {
continue;
}
let found = absolute_path(&dir.join(g.link.name()).to_string_lossy());
@@ -1482,7 +1568,7 @@ fn linked_param(st: &crate::DocState, p: &Value, cmd: &str) -> Result<NodeId> {
pub(crate) fn statuses(st: &crate::DocState) -> BTreeMap<NodeId, (Status, Option<String>, bool)> {
let mut out = BTreeMap::new();
for g in groups(&st.doc, None).iter().filter(|g| !g.ids.is_empty()) {
let (status, found) = probe(g, st.path.as_deref());
let (status, found) = probe(g, LinkBase::of(st));
let found = found.map(|f| f.path).filter(|p| *p != g.link.path);
let preview = stale(&st.doc, g);
out.extend(g.ids.iter().map(|id| (*id, (status, found.clone(), preview))));
@@ -1603,7 +1689,7 @@ fn embed(s: &mut Session, p: &Value) -> Result<Value> {
Some(NodeKind::PlacedDocument(_)) => true,
_ => false,
};
let ids = ids_param(p, "ids").unwrap_or_else(|| st.selection.objects.iter().copied().filter(linked).collect());
let ids = ids_param(p, "ids", C)?.unwrap_or_else(|| st.selection.objects.iter().copied().filter(linked).collect());
if let Some(id) = ids.iter().find(|id| !matches!(kind(id), Some(NodeKind::Image(_) | NodeKind::PlacedDocument(_)))) {
return Err(bad(C, format!("object {} is not an image or a placed document", id.0)));
}
@@ -1625,7 +1711,7 @@ fn embed(s: &mut Session, p: &Value) -> Result<Value> {
done.extend(g.ids);
continue;
}
match probe(&g, st.path.as_deref()) {
match probe(&g, LinkBase::of(st)) {
(Status::Ok | Status::Modified, Some(mut f)) => match read_image(&mut f, &g.link) {
Ok(read) => {
done.extend(&g.ids);
@@ -1714,8 +1800,9 @@ fn file_fields(st: &crate::DocState, id: NodeId, l: &LinkInfo, out: &mut Value)
}
fn file_fields_here(st: &crate::DocState, id: NodeId, l: &LinkInfo, out: &mut Value) {
let status = groups(&st.doc, Some(&[id])).first().map_or(Status::Missing, |g| probe(g, st.path.as_deref()).0);
let doc_path = st.path.as_deref();
let base = LinkBase::of(st);
let status = groups(&st.doc, Some(&[id])).first().map_or(Status::Missing, |g| probe(g, base).0);
let doc_path = base.doc;
let path = fileio::document_path(&l.path, doc_path).ok();
let stamp = path.as_deref().and_then(file_stamp);
out["status"] = json!(status.as_str());
@@ -1792,7 +1879,8 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
fn placement_options(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "links.placementOptions";
let st = s.doc()?;
let ids = ids_param(p, "ids").unwrap_or_else(|| st.selection.objects.iter().copied().filter(|id| placement_of(&st.doc, *id).is_some()).collect());
let ids =
ids_param(p, "ids", C)?.unwrap_or_else(|| st.selection.objects.iter().copied().filter(|id| placement_of(&st.doc, *id).is_some()).collect());
if let Some(id) = ids.iter().find(|id| placement_of(&st.doc, **id).is_none()) {
return Err(bad(C, format!("object {} is not an image or a linked file", id.0)));
}
+2 -2
View File
@@ -114,7 +114,7 @@ fn apply_transform_moving_guides(
let c = xf.as_coeffs();
let is_translation = (c[0] - 1.0).abs() < 1e-9 && c[1].abs() < 1e-9 && c[2].abs() < 1e-9 && (c[3] - 1.0).abs() < 1e-9;
let delta = guides.unwrap_or(Vec2::new(c[4], c[5]));
let with_guides = objects && ids_param(p, "ids").is_none() && is_translation;
let with_guides = objects && p.get("ids").is_none_or(Value::is_null) && is_translation;
let has_guides = with_guides && !s.doc()?.selection.guides.is_empty();
// A transform of ids that name no object, or an identity transform without a copy, moves
// nothing: the document and its undo history stay as they are, and Transform Again repeats it.
@@ -254,7 +254,7 @@ pub(super) fn transform(s: &mut Session, p: &Value) -> Result<Value> {
if p.get("ids").is_none() && crate::cmd::brushsym::selected_part(s)?.is_some() {
return Ok(json!({ "warning": crate::cmd::brushsym::part_only().to_string() }));
}
let (ids, anchors) = match ids_param(p, "ids") {
let (ids, anchors) = match ids_param(p, "ids", "object.transform")? {
Some(v) => (outermost(&s.doc()?.doc, v), false),
None => (selected_roots_to_transform(s)?, bool_or(p, "anchors", false)),
};
+1 -1
View File
@@ -210,7 +210,7 @@ fn make(s: &mut Session, p: &Value) -> Result<Value> {
Some(id) => Err(bad(C, format!("object {} is hidden or locked, or on a locked artboard", id.0))),
None => Ok(()),
};
let mut ids = match ids_param(p, "ids") {
let mut ids = match ids_param(p, "ids", C)? {
Some(ids) => {
if let Some(id) = ids.iter().find(|id| d.node(**id).is_none()) {
return Err(EngineError::NoNode(*id));
+1 -1
View File
@@ -229,7 +229,7 @@ pub(crate) fn overprint_black(s: &mut Session, p: &Value) -> Result<Value> {
rich: bool_or(p, "includeCmyBlacks", false),
spot: bool_or(p, "includeSpotBlacks", false),
};
let ids = match ids_param(p, "ids") {
let ids = match ids_param(p, "ids", "edit.colors.overprintBlack")? {
Some(v) => v,
None => selected_roots(s)?,
};
+38 -11
View File
@@ -13,6 +13,7 @@ use vectorcraft_doc::{Document, LinkInfo};
use serde_json::{Value, json};
use super::fileio::{create_dir, write_file};
use super::links::LinkBase;
use super::{CommandSpec, Result, Session, bad, bool_or, cmd, has_doc, str_param, unique_name};
const C: &str = "file.package";
@@ -187,8 +188,18 @@ impl<'a> Collector<'a> {
}
/// Collects the files `doc` links. `by_name`: `doc` is a placed `.ai` file's editing data,
/// whose links are not rewritten, so its copy finds them by name beside it.
fn collect(&mut self, doc: &mut Document, source: &str, destination: &str, depth: usize, by_name: bool) -> Result<()> {
/// whose links are not rewritten, so its copy finds them by name beside it. `lookup`: the
/// document whose links (with the same paths as `doc`'s) are looked for, and where from, when
/// not `doc` from `source`.
fn collect(
&mut self,
doc: &mut Document,
source: &str,
destination: &str,
depth: usize,
by_name: bool,
lookup: Option<(&Document, LinkBase)>,
) -> Result<()> {
// The caller checks cycles and depth before descending; active documents have
// a destination reserved so back-links can point at their single packaged copy.
self.visiting.insert(source.to_string(), destination.to_string());
@@ -210,6 +221,10 @@ impl<'a> Collector<'a> {
// The fonts of the type on the PDF and `.ai` pages placed linked.
let mut placed_fonts = BTreeSet::new();
let mut placed = BTreeMap::<String, PlacedPages>::new();
let linked_files = |doc: &Document| match lookup {
Some((d, base)) => super::links::linked_files(d, base),
None => super::links::linked_files(doc, LinkBase::file(Some(source))),
};
doc.visit_placed(|_, p| {
let page = (p.link.page.unwrap_or(1), p.bounding, p.page_box);
let pages = placed.entry(p.link.path.clone()).or_default();
@@ -218,7 +233,7 @@ impl<'a> Collector<'a> {
}
});
if self.opts.copy_fonts && !self.opts.copy_links && !placed.is_empty() {
for file in super::links::linked_files(doc, Some(source)) {
for file in linked_files(doc) {
if let (Some(pages), Some(bytes)) = (placed.get(&file.path), &file.bytes) {
placed_fonts.extend(page_fonts(&file.name, bytes, file.found_path.as_deref().unwrap_or(&file.path), pages));
}
@@ -226,7 +241,7 @@ impl<'a> Collector<'a> {
}
if self.opts.copy_links {
heading(&mut lines, "LINKED FILES");
for file in super::links::linked_files(doc, Some(source)) {
for file in linked_files(doc) {
let found_path = file.found_path.as_deref().unwrap_or(&file.path);
if let Some(ancestor) = self.visiting.get(found_path).cloned() {
// The ancestor is already included (possibly the root document). A
@@ -272,7 +287,7 @@ impl<'a> Collector<'a> {
if depth >= vectorcraft_doc::placed_document::MAX_DEPTH {
self.warn(&mut lines, format!("{}: nesting limit reached; copied unchanged without collecting its own links", file.path));
} else {
self.collect(&mut art, found_path, &destination_file, depth + 1, true)?;
self.collect(&mut art, found_path, &destination_file, depth + 1, true, None)?;
}
} else if self.opts.copy_fonts
&& let Some(pages) = pages
@@ -289,7 +304,7 @@ impl<'a> Collector<'a> {
let include_linked =
saved.doc.linked_only_images().iter().any(|key| saved.doc.images.get(key).is_some_and(|blob| !blob.is_proxy()));
let mut nested = saved.doc;
self.collect(&mut nested, found_path, &destination_file, depth + 1, false)?;
self.collect(&mut nested, found_path, &destination_file, depth + 1, false, None)?;
if self.opts.relink {
#[cfg(test)]
PARTS_READ.with(|c| c.set(c.get() + 1));
@@ -405,6 +420,8 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> {
pub struct Package {
o: Options,
doc_path: String,
/// The file the document's links' stored relative paths are taken from.
links_from: String,
stem: String,
name: String,
root: Option<PathBuf>,
@@ -450,7 +467,8 @@ fn prepare(s: &mut Session, p: &Value) -> Result<Package> {
let root = folder.map(|f| Path::new(f).join(&name));
let info = if o.report { Some(super::docinfo::report(s, false)?) } else { None };
let doc = s.doc()?.doc.clone();
Ok(Package { o, doc_path, stem, name, root, info, doc })
let links_from = s.doc()?.links_from.clone().unwrap_or_else(|| doc_path.clone());
Ok(Package { o, doc_path, links_from, stem, name, root, info, doc })
}
impl Package {
@@ -464,18 +482,27 @@ impl Package {
/// [`Package::write`].
fn collect_and_write(package: Package, write: &mut dyn FnMut(&str, &[u8]) -> Result<()>) -> Result<Value> {
let Package { o, doc_path, stem, name, root, info, doc } = package;
let mut doc = (*doc).clone();
let Package { o, doc_path, links_from, stem, name, root, info, doc: original } = package;
let mut doc = (*original).clone();
let doc_name = format!("{stem}.{}", vectorcraft_format::EXTENSION);
let base = LinkBase { doc: Some(&doc_path), relative: Some(&links_from) };
// After a save in another folder, links found away from their paths take the paths where they
// were found, as a copy of the document has them.
let found = (links_from != doc_path).then(|| {
super::links::with_found_paths(&mut doc, base);
doc.clone()
});
let looked = found.as_ref().unwrap_or(&original);
// Prepare original links for the destination *before* rebasing copied links.
// Recalculating afterward would overwrite the portable relative paths.
if let Some(root) = &root
&& let Some(d) = super::links::with_relative_paths(&doc, &root.join(&doc_name).to_string_lossy())
&& let Some(d) = super::links::with_relative_paths(&doc, base, &root.join(&doc_name).to_string_lossy())
{
doc = d;
}
let mut collector = Collector::new(&o, root.clone(), &doc_name);
collector.collect(&mut doc, &doc_path, &doc_name, 0, false)?;
// The files are looked for where the links name them before the rebasing above.
collector.collect(&mut doc, &doc_path, &doc_name, 0, false, Some((looked, base)))?;
let mut entries = std::mem::take(&mut collector.entries);
entries.insert(0, (doc_name.clone(), vectorcraft_format::save_file(&doc)));
let mut taken = std::mem::take(&mut collector.taken);
+1 -1
View File
@@ -950,7 +950,7 @@ fn proxy_recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn(
// The proxy recolors its paints, including a colorized image's fill, without changing image pixels or pattern tiles.
ItemTarget::Top => {
let q = json!({"fill": !stroke, "stroke": stroke, "includeImages": false, "includePatterns": false, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()});
(super::colorcmds::recolor(s, &q, label, f)?, None)
(super::colorcmds::recolor(s, &q, cmd, label, f)?, None)
}
};
let shown_ids = if shown_item.is_some() { ids } else { leaf_targets(s, &ids)? };
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -597,7 +597,7 @@ pub(super) fn join(s: &mut Session, p: &Value) -> Result<Value> {
},
};
if let Some(ids) = ids_param(p, "ids") {
if let Some(ids) = ids_param(p, "ids", C)? {
if ends.is_some() && ids.len() > 2 {
return Err(bad(C, "`ends` applies to joining exactly two paths"));
}
@@ -1101,7 +1101,7 @@ fn average_join_ends(s: &Session, p: &Value, c: &str) -> Result<Option<[End; 2]>
let doc = &st.doc;
let subpath = |id: NodeId, si: usize| doc.node(id)?.path_data()?.subpaths.get(si).filter(|sp| !sp.closed && !sp.anchors.is_empty());
let last = |sp: &SubPath| sp.anchors.len().saturating_sub(1);
let ids = ids_param(p, "ids");
let ids = ids_param(p, "ids", c)?;
let ends = if ids.is_none() && !st.selection.anchors.is_empty() {
let picked: Vec<End> = st.selection.anchors.iter().flat_map(|(&id, set)| set.iter().map(move |&(si, ai)| (id, si, ai))).collect();
match picked.as_slice() {
+9 -9
View File
@@ -219,8 +219,8 @@ fn ids_json(ids: &[NodeId]) -> Value {
json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() })
}
fn roots(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
match ids_param(p, "ids") {
fn roots(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
match ids_param(p, "ids", cmd)? {
Some(ids) => {
let st = s.doc()?;
let mut sel = Selection::default();
@@ -306,7 +306,7 @@ fn pattern_make(s: &mut Session, p: &Value) -> Result<Value> {
if editing(s).is_some() {
return Err(bad(C, "already in pattern editing mode"));
}
let roots = roots(s, p)?;
let roots = roots(s, p, C)?;
if roots.is_empty() {
return Err(bad(C, "select the art for the pattern tile"));
}
@@ -665,9 +665,9 @@ fn pattern_transform(s: &mut Session, p: &Value) -> Result<Value> {
// ---------- Repeat ----------
/// Make a repeat from the selection, or switch the selected repeat's kind.
fn make_repeat(s: &mut Session, p: &Value, label: &str, make: impl Fn(Vec<Arc<Node>>) -> RepeatSpec) -> Result<Value> {
fn make_repeat(s: &mut Session, p: &Value, cmd: &str, label: &str, make: impl Fn(Vec<Arc<Node>>) -> RepeatSpec) -> Result<Value> {
let existing = selected_repeats(s);
if ids_param(p, "ids").is_none()
if ids_param(p, "ids", cmd)?.is_none()
&& let [r] = existing.as_slice()
{
let r = *r;
@@ -680,7 +680,7 @@ fn make_repeat(s: &mut Session, p: &Value, label: &str, make: impl Fn(Vec<Arc<No
})?;
return Ok(json!({ "id": r.0 }));
}
let roots = roots(s, p)?;
let roots = roots(s, p, cmd)?;
if roots.is_empty() {
return Err(EngineError::Other("select the art to repeat".into()));
}
@@ -699,14 +699,14 @@ fn repeat_radial(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad("object.repeat.radial", "instances must be 1..1000"));
}
let radius = p.get("radius").and_then(Value::as_f64).filter(|r| r.is_finite() && *r >= 0.0);
make_repeat(s, p, "Radial Repeat", |src| RepeatSpec::radial(src, n as u32, radius))
make_repeat(s, p, "object.repeat.radial", "Radial Repeat", |src| RepeatSpec::radial(src, n as u32, radius))
}
fn repeat_grid(s: &mut Session, p: &Value) -> Result<Value> {
let (hs, vs) = (p.get("hSpacing").and_then(Value::as_f64), p.get("vSpacing").and_then(Value::as_f64));
let rows = p.get("rows").and_then(Value::as_u64).map(|v| v.clamp(1, 500) as u32);
let cols = p.get("cols").and_then(Value::as_u64).map(|v| v.clamp(1, 500) as u32);
make_repeat(s, p, "Grid Repeat", |src| {
make_repeat(s, p, "object.repeat.grid", "Grid Repeat", |src| {
let b = vectorcraft_doc::live::nodes_bounds(&src).unwrap_or(Rect::new(0.0, 0.0, 10.0, 10.0));
let mut r = RepeatSpec::grid(src, hs.unwrap_or(b.width() / 4.0), vs.unwrap_or(b.height() / 4.0));
if let RepeatKind::Grid { rows: rr, cols: cc, .. } = &mut r.kind {
@@ -720,7 +720,7 @@ fn repeat_grid(s: &mut Session, p: &Value) -> Result<Value> {
fn repeat_mirror(s: &mut Session, p: &Value) -> Result<Value> {
let angle = f64_or(p, "angle", 90.0);
let offset = f64_or(p, "offset", 10.0);
make_repeat(s, p, "Mirror Repeat", |src| RepeatSpec::mirror(src, angle, offset))
make_repeat(s, p, "object.repeat.mirror", "Mirror Repeat", |src| RepeatSpec::mirror(src, angle, offset))
}
fn repeat_release(s: &mut Session, _: &Value) -> Result<Value> {
+19 -1
View File
@@ -96,7 +96,9 @@ pub(crate) fn import(d: &mut Document, lib: &Document, name: &str) -> String {
pub(crate) fn existing_pattern(d: &Document, lib: &Document, name: &str) -> Option<String> {
let def = lib.pattern(name)?;
let bare = |p: &PatternDef| PatternDef { name: String::new(), art: vec![], ..p.clone() };
let copy = |n: &str| n == name || n.strip_prefix(name).and_then(|r| r.strip_prefix(' ')).is_some_and(|r| r.parse::<u32>().is_ok());
let copy = |n: &str| {
n == name || n.strip_prefix(name).and_then(|r| r.strip_prefix(' ')).is_some_and(|r| !r.is_empty() && r.bytes().all(|b| b.is_ascii_digit()))
};
let mut found: Vec<&PatternDef> = d.patterns.iter().filter(|p| copy(&p.name) && bare(p) == bare(def) && p.art == def.art).collect();
found.sort_by_key(|p| p.name != name);
found.first().map(|p| p.name.clone())
@@ -150,3 +152,19 @@ fn geometric_patterns(d: &mut Document) {
add(d, "Crosses", 10.0, 10.0, TileType::Grid, vec![rect(4.0, 1.5, 6.0, 8.5), rect(1.5, 4.0, 8.5, 6.0)]);
add(d, "Zigzag", 10.0, 8.0, TileType::Grid, vec![polygon(&[(0.0, 5.0), (5.0, 1.0), (10.0, 5.0), (10.0, 7.0), (5.0, 3.0), (0.0, 7.0)])]);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plus_signed_suffix_is_not_a_copy_number() {
let mut lib = Document::new(100.0, 100.0);
lib.patterns.push(PatternDef::new("Dots", vec![]));
for (other, expected) in [("Dots +2", None), ("Dots -2", None), ("Dots 2a", None), ("Dots ", None), ("Dots 2", Some("Dots 2"))] {
let mut d = Document::new(100.0, 100.0);
d.patterns.push(PatternDef::new(other, vec![]));
assert_eq!(existing_pattern(&d, &lib, "Dots").as_deref(), expected, "{other}");
}
}
}
+2 -2
View File
@@ -254,7 +254,7 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>, link_pdf: bool) -> Result<Loa
));
}
// Linked images show their files, found from its folder.
super::links::resolve(&mut l.doc, src.path, false, false);
super::links::resolve(&mut l.doc, super::links::LinkBase::file(src.path), false, false);
(l.doc, l.warnings)
};
doc.drop_edit_modes();
@@ -312,7 +312,7 @@ pub(crate) fn pdf_art(
// file again: one already being read further up, or past the most nested reads, keeps its links
// as stored, as a VectorCraft document's do.
if alone && let Some(_reading) = document::Reading::enter(bytes) {
super::links::resolve(&mut doc, path, false, false);
super::links::resolve(&mut doc, super::links::LinkBase::file(path), false, false);
}
doc.drop_edit_modes();
let layers = std::mem::take(&mut doc.layers);
+2 -2
View File
@@ -133,7 +133,7 @@ pub(crate) fn document_art(
// A document already being read further up, or past [`MAX_NESTED_READS`] of them, has its links
// left as stored.
if let Some(_reading) = Reading::enter(bytes) {
super::super::links::resolve(&mut l.doc, path, false, false);
super::super::links::resolve(&mut l.doc, super::super::links::LinkBase::file(path), false, false);
}
l.doc.drop_edit_modes();
let n = l.doc.artboards.len();
@@ -332,7 +332,7 @@ pub(crate) fn node(d: &mut Document, s: &Source, link: LinkInfo, bounding: bool)
/// `d` for output: linked images and placed documents that keep only their previews get their
/// files back, else output as their previews, with a warning.
pub(crate) fn full_documents(d: &Document) -> (Cow<'_, Document>, Vec<String>) {
let (d, mut warnings) = super::super::links::full_linked_images(d, None);
let (d, mut warnings) = super::super::links::full_linked_images(d, Default::default());
let doc = match &d {
Cow::Borrowed(doc) => doc,
Cow::Owned(doc) => doc,
+128 -27
View File
@@ -24,7 +24,10 @@
//! `vc_cancelled` and `vc_log` ([`vectorcraft_plugins::host`]): run inside a
//! [`vectorcraft_doc::cancel::scope`] (the app's heavy filters and background exports), its
//! progress goes to the scope's sink and it sees the scope's Cancel; its log lines go to the log
//! under its id.
//! under its id. One that lists `settings` or `cache` may keep settings and a cache of its own
//! (`vc_settings_*`, `vc_cache_*`), in the plug-in store under its id ([`crate::store`]):
//! `plugin.clearCache` empties the cache, `plugin.list` and `plugin.info` give its size
//! (`cacheBytes`), replacing the plug-in keeps both and `plugin.remove` deletes them.
use std::sync::{Arc, Mutex};
@@ -141,7 +144,7 @@ impl FilterRun {
let st = s.doc()?;
// While an object of a dynamic symbol instance's art is selected, the selection is refused,
// as `selected_roots` refuses it.
let selected = match ids_param(p, "ids") {
let selected = match ids_param(p, "ids", C)? {
Some(ids) => ids,
None => {
super::edit::refuse_symbol_part(s)?;
@@ -340,14 +343,28 @@ fn grant_param(c: &str, p: &Value) -> Result<Option<Vec<Permission>>> {
Ok(Some(out))
}
/// Would installing `plugin` take another module's place: one installed with its id and other
/// bytes, or, with none installed, the settings and cache the plug-in store keeps for its id?
fn takes_over(s: &Session, plugin: &Plugin) -> bool {
match registry::get(plugin.id()) {
Some(installed) => installed.hash() != plugin.hash(),
None => s.has_plugin_storage(plugin.id()),
}
}
/// `plugin.install`: loads and checks the module, refuses one that asks for a heavy budget unless
/// `allowHeavy` says the user agreed, and one that lists permissions the user hasn't granted this
/// very module unless `grant` lists them all, and one whose SHA-256 isn't the `hash` given; records
/// the grant with the module's hash, keeps it for the next start and installs it → its
/// `plugin.list` entry. A module that lists no permissions ends any grant held for its id.
/// `allowHeavy` says the user agreed, one that lists permissions the user hasn't granted this very
/// module unless `grant` lists them all, one that would take another module's place ([`takes_over`])
/// unless `replace` is true, and one whose SHA-256 isn't the `hash` given; records the grant with
/// the module's hash, keeps it for the next start and installs it → its `plugin.list` entry. A
/// module that lists no permissions ends any grant held for its id.
fn install(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "plugin.install";
let (replace, allow_heavy) = (switch_or(p, "replace", true, C)?, switch_or(p, "allowHeavy", false, C)?);
let replace = match p.get("replace") {
None | Some(Value::Null) => None,
Some(_) => Some(switch_or(p, "replace", false, C)?),
};
let allow_heavy = switch_or(p, "allowHeavy", false, C)?;
let grant = grant_param(C, p)?;
let (plugin, bytes) = load_given(C, p)?;
// `hash` pins the module the user was shown: one with other bytes is refused.
@@ -356,9 +373,21 @@ fn install(s: &mut Session, p: &Value) -> Result<Value> {
{
return Err(bad(C, "the module doesn't have the SHA-256 `hash` gives: nothing was installed"));
}
if !replace && registry::get(plugin.id()).is_some() {
if replace == Some(false) && registry::get(plugin.id()).is_some() {
return Err(bad(C, format!("a plug-in with id {:?} is already installed", plugin.id())));
}
// Another module under the id gets the settings and cache kept for it: whoever installs it asks
// the user first.
if replace != Some(true) && takes_over(s, &plugin) {
let name = permissions::display_text(&plugin.manifest().name, permissions::MAX_SHOWN_NAME);
return Err(bad(
C,
format!(
"“{name}” is another module than the plug-in with id {:?} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees",
plugin.id()
),
));
}
// A heavy plug-in is installed once the user agrees: whoever installs it asks them first.
if plugin.manifest().budget == Budget::Heavy && !allow_heavy {
let (minutes, times) = vectorcraft_plugins::heavy_terms();
@@ -379,21 +408,31 @@ fn install(s: &mut Session, p: &Value) -> Result<Value> {
let asked = permissions::names(&wanted).join(", ");
return Err(bad(C, format!("“{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees")));
}
// Installed plug-ins stay installed after a restart (#2541). The module is written first and its
// grant only once it is there; when the grant can't be written, the store gets the module it
// held back. A failed write of that module is only logged, and the grant then doesn't cover
// the new module. A stored module that can't be read back isn't replaced by one that needs a
// grant.
let previous = s.stored_plugin_bytes(plugin.id());
let mut stored = (previous.is_ok() || !m.asks_permissions()) && s.store_plugin(plugin.id(), &bytes);
if m.asks_permissions() {
let g = Grant { id: m.id.clone(), hash: plugin.hash().to_string(), permissions: wanted };
registry::grant(g.clone());
s.store_grant(&m.id, Some(g));
if stored && !s.store_grant(&m.id, Some(g)) {
s.put_back_plugin(plugin.id(), previous.ok().flatten().as_deref());
stored = false;
}
} else {
// This module takes the id over, so nothing the user approved for the id applies any more.
registry::revoke(&m.id);
// When the write failed, the old stored module stays in the folder and needs its grant at
// the next launch.
if stored {
s.store_grant(&m.id, None);
}
}
// Installed plug-ins stay installed after a restart (#2541).
let stored = s.store_plugin(plugin.id(), &bytes);
// When the write fails, the old stored module stays in the folder and needs its grant at the
// next launch.
if stored && !m.asks_permissions() {
s.store_grant(&m.id, None);
}
// It gets the settings and cache kept for its id.
s.attach_plugin_storage(plugin.id());
// Installing a disabled plug-in again (an updated version) turns it on for the next launch too,
// also when it is disabled by its stored file alone (it stopped a launch while loading). When
// the new version could not replace the stored file, the old one stays disabled.
@@ -408,15 +447,17 @@ fn install(s: &mut Session, p: &Value) -> Result<Value> {
if changed {
s.store_disabled_plugins();
}
Ok(describe(&registry::install(plugin)))
Ok(describe_installed(&registry::install(plugin)))
}
/// `plugin.inspect`: what installing the module would install, without installing it, and whether
/// that needs the user to grant its permissions first (`needsApproval`).
/// `plugin.inspect`: what installing the module would install, without installing it, whether that
/// needs the user to grant its permissions first (`needsApproval`) and whether it would take
/// another module's place (`needsReplace`).
fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
load_given("plugin.inspect", p).map(|(plugin, _)| {
let mut v = describe(&plugin);
v["needsApproval"] = json!(!s.plugin_approved(&plugin));
v["needsReplace"] = json!(takes_over(s, &plugin));
v
})
}
@@ -487,6 +528,9 @@ fn load_folder(s: &Session, folder: &str) -> Value {
};
match registry::load_folder_where(std::path::Path::new(folder), allow, s.plugin_approver()) {
Ok(r) => {
for id in &r.loaded {
s.attach_plugin_storage(id);
}
if r.disabled_changed {
s.store_disabled_plugins();
}
@@ -518,11 +562,18 @@ fn reload(s: &mut Session, p: &Value) -> Result<Value> {
Ok(report)
}
/// [`describe`] for an installed plug-in, with the bytes its cache holds (`cacheBytes`).
fn describe_installed(p: &Plugin) -> Value {
let mut v = describe(p);
v["cacheBytes"] = json!(registry::storage(p.id()).map_or(0, |st| st.cache_bytes()));
v
}
fn list(_: &mut Session, _: &Value) -> Result<Value> {
let folder = FOLDER.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|(_, _, r)| r.clone());
let disabled: Vec<Value> = safe_mode::disabled().iter().map(safe_mode::Item::to_json).collect();
Ok(
json!({"plugins": registry::list().iter().map(|p| describe(p)).collect::<Vec<_>>(), "folder": folder, "safeMode": safe_mode::active(), "disabled": disabled}),
json!({"plugins": registry::list().iter().map(|p| describe_installed(p)).collect::<Vec<_>>(), "folder": folder, "safeMode": safe_mode::active(), "disabled": disabled}),
)
}
@@ -577,7 +628,7 @@ fn set_enabled(s: &mut Session, p: &Value) -> Result<Value> {
fn info(_: &mut Session, p: &Value) -> Result<Value> {
let plugin = plugin_by_id("plugin.info", p)?;
let mut v = describe(&plugin);
let mut v = describe_installed(&plugin);
v["manifest"] = serde_json::to_value(plugin.manifest()).unwrap_or(Value::Null);
v["defaults"] = Value::Object(plugin.manifest().defaults());
v["lastError"] = json!(effect::last_error(plugin.id()));
@@ -586,15 +637,48 @@ fn info(_: &mut Session, p: &Value) -> Result<Value> {
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
let id = str_param(p, "id").ok_or_else(|| bad("plugin.remove", "missing `id`"))?;
let installed = registry::get(id).is_some();
// Settings and cache the store keeps for an id that isn't installed are deleted too.
let stored = !installed && vectorcraft_plugins::manifest::valid_id(id) && s.has_plugin_storage(id);
// Its settings and cache go first: when they can't be moved aside, the plug-in stays installed,
// as it was, and a second `plugin.remove` tries again.
if (installed || stored)
&& let Err(e) = s.remove_plugin_storage(id)
{
if installed {
s.attach_plugin_storage(id);
}
return Err(EngineError::Other(e.to_string()));
}
// Installed again, it asks again.
registry::revoke(id);
s.store_grant(id, None);
s.unstore_plugin(id);
if registry::remove(id) { Ok(json!({"removed": id})) } else { Err(err(vectorcraft_plugins::Error::NotFound(id.into()))) }
if !stored && (!installed || !registry::remove(id)) {
return Err(err(vectorcraft_plugins::Error::NotFound(id.into())));
}
Ok(json!({"removed": id}))
}
/// `plugin.clearCache`: empties the cache of plug-in `id`, else of every installed plug-in →
/// `{cleared: [id], bytesFreed}`. Settings stay.
fn clear_cache(_: &mut Session, p: &Value) -> Result<Value> {
let plugins = match p.get("id").filter(|v| !v.is_null()) {
Some(_) => vec![plugin_by_id("plugin.clearCache", p)?],
None => registry::list(),
};
let (mut cleared, mut freed) = (Vec::new(), 0_u64);
for plugin in plugins {
if let Some(st) = registry::storage(plugin.id()) {
freed += st.clear_cache().map_err(|e| EngineError::Other(e.to_string()))?;
}
cleared.push(plugin.id().to_string());
}
Ok(json!({"cleared": cleared, "bytesFreed": freed}))
}
/// This module's commands: `plugin.list`, `.info`, `.run`, `.install`, `.inspect`, `.remove`,
/// `.reload`, `.safeMode` and `.setEnabled`.
/// `.reload`, `.clearCache`, `.safeMode` and `.setEnabled`.
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
@@ -602,7 +686,7 @@ pub fn specs() -> Vec<CommandSpec> {
"List Plug-ins",
[],
None,
"{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.<id>), format? and extensions? (an exporter's format id, plugin.<id>, for document.export, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null, safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}",
"{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.<id>), format? and extensions? (an exporter's format id, plugin.<id>, for document.export, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines; settings: it may keep settings of its own; cache: it may keep a cache of its own), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2), cacheBytes (the bytes its cache holds; see plugin.clearCache)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null, safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}",
always,
list
),
@@ -629,7 +713,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Install Plug-in",
[],
None,
"{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true: a plug-in with the same id is replaced), name?: file name (with dataBase64), allowHeavy?: bool (true once the user agreed to a heavy budget: a filter whose manifest asks for one, budget \"heavy\" in plugin.inspect, installs only with it), grant?: [permission] (the permissions the user granted: a plug-in whose manifest lists permissions, needsApproval in plugin.inspect, installs only when grant holds every one of them; the grant is recorded with the module's hash and covers only that module, so a module with other bytes needs a new grant even when it lists the same permissions; installing a module that lists no permissions forgets the grant held for its id; with a plug-in store attached, only the grants kept there count), hash?: string (the module's SHA-256 as plugin.inspect gave it: a module with other bytes is refused)} validate and install a WebAssembly plug-in for this session → its plugin.list entry",
"{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true once the user agreed: a module with other bytes than the plug-in installed with its id, or with none installed, one whose id has settings or a cache in the plug-in store, installs only with it, takes that plug-in's place and gets its settings and cache; false: one whose id is installed is refused; left out, the same module installs again), name?: file name (with dataBase64), allowHeavy?: bool (true once the user agreed to a heavy budget: a filter whose manifest asks for one, budget \"heavy\" in plugin.inspect, installs only with it), grant?: [permission] (the permissions the user granted: a plug-in whose manifest lists permissions, needsApproval in plugin.inspect, installs only when grant holds every one of them; the grant is recorded with the module's hash and covers only that module, so a module with other bytes needs a new grant even when it lists the same permissions; installing a module that lists no permissions forgets the grant held for its id; with a plug-in store attached, only the grants kept there count), hash?: string (the module's SHA-256 as plugin.inspect gave it: a module with other bytes is refused)} validate and install a WebAssembly plug-in for this session → its plugin.list entry",
always,
install
),
@@ -638,11 +722,28 @@ pub fn specs() -> Vec<CommandSpec> {
"Inspect Plug-in",
[],
None,
"{path: a .wasm file (desktop) | dataBase64: the module, name?: the file name shown as its source (with dataBase64)} validate a WebAssembly plug-in without installing it → the plugin.list entry it would have, plus needsApproval: bool (true when it lists permissions the user hasn't granted this very module, its bytes included: plugin.install then needs grant)",
"{path: a .wasm file (desktop) | dataBase64: the module, name?: the file name shown as its source (with dataBase64)} validate a WebAssembly plug-in without installing it → the plugin.list entry it would have, plus needsApproval: bool (true when it lists permissions the user hasn't granted this very module, its bytes included: plugin.install then needs grant) and needsReplace: bool (true when it would take the place of another module with its id: plugin.install then needs replace: true)",
always,
inspect
),
cmd!(query "plugin.remove", "Remove Plug-in", [], None, "{id} uninstall a plug-in (its live effects then leave the geometry as it is) → {removed}", always, remove),
cmd!(
query "plugin.remove",
"Remove Plug-in",
[],
None,
"{id} uninstall a plug-in (its live effects then leave the geometry as it is) and delete the settings and cache it kept (when their folder can't be moved aside, the command fails and the plug-in stays installed); for an id that isn't installed, deletes the settings and cache the plug-in store still keeps for it → {removed}",
always,
remove
),
cmd!(
query "plugin.clearCache",
"Clear Plug-in Cache",
[],
None,
"{id?} delete every entry in the cache of plug-in id (default: of every installed plug-in); its settings stay → {cleared: [id], bytesFreed}",
always,
clear_cache
),
cmd!(
query "plugin.reload",
"Reload Plug-ins",
+10 -2
View File
@@ -575,6 +575,10 @@ struct Meta {
/// (see [`crate::DocState::import_losses`]): a restored copy still refuses to replace it.
imported_from: Option<String>,
import_losses: Vec<String>,
/// The file the links' relative paths name places from, for a copy without `path` (see
/// [`crate::DocState::links_from`]).
#[serde(skip_serializing_if = "Option::is_none")]
links_from: Option<String>,
/// The document's images by key, in entries of the area (none in copies written before
/// images were kept apart: the document entry holds them).
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
@@ -844,6 +848,7 @@ pub fn jobs(s: &mut Session, store: &Arc<dyn RecoveryStore>) -> Result<(Vec<Reco
saved,
imported_from: st.imported_from.clone(),
import_losses: st.import_losses.clone(),
links_from: st.links_from.clone().filter(|_| st.path.is_none()),
// Filled in as the copy is written.
images: BTreeMap::new(),
};
@@ -1061,11 +1066,14 @@ fn restore_one(s: &mut Session, store: &Arc<dyn RecoveryStore>, file: &str) -> R
if doc.title.is_empty() {
doc.title = if meta.title.is_empty() { stem.to_string() } else { meta.title.clone() };
}
// Linked files are looked for from the document's own folder.
let links = super::links::resolve(&mut doc, meta.path.as_deref(), s.prefs.update_links == "automatically", s.prefs.low_res_proxy_eps);
// Linked files are looked for from the document's own folder, else from the file it was read from.
let links_from = meta.path.clone().or_else(|| meta.links_from.clone());
let base = super::links::LinkBase::file(links_from.as_deref());
let links = super::links::resolve(&mut doc, base, s.prefs.update_links == "automatically", s.prefs.low_res_proxy_eps);
let index = s.add_document(doc, meta.path.clone());
let st = s.doc_mut()?;
st.recovered = true;
st.links_from = links_from;
st.imported_from = meta.imported_from.clone();
st.import_losses = meta.import_losses.clone();
st.format = SAVE_FORMATS.iter().copied().find(|f| *f == meta.format).unwrap_or("vectorcraft");
+4 -3
View File
@@ -204,7 +204,7 @@ impl Session {
/// The slices a command acts on: `slices` (ids that must all be slices), else the selected ones.
fn slice_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
let st = s.doc()?;
match ids_param(p, "slices") {
match ids_param(p, "slices", cmd)? {
Some(ids) => match ids.iter().find(|id| !st.doc.is_slice(**id)) {
Some(id) => Err(bad(cmd, format!("{id} is not a slice"))),
None => Ok(dedup(ids)),
@@ -290,7 +290,7 @@ pub(crate) fn options_json(o: &SliceOptions) -> Value {
// ---------- Object → Slice ----------
fn make(s: &mut Session, p: &Value) -> Result<Value> {
let ids = match ids_param(p, "ids") {
let ids = match ids_param(p, "ids", "object.slice.make")? {
Some(ids) => super::edit::outermost(&s.doc()?.doc, ids),
None => super::edit::selected_roots(s)?,
};
@@ -734,7 +734,8 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
}
fn select(s: &mut Session, p: &Value) -> Result<Value> {
let ids = slice_targets(s, &json!({ "slices": p.get("slices").cloned().unwrap_or(json!([])) }), "object.slice.select")?;
let slices = p.get("slices").filter(|v| !v.is_null()).cloned().unwrap_or(json!([]));
let ids = slice_targets(s, &json!({ "slices": slices }), "object.slice.select")?;
let toggle = bool_or(p, "toggle", false);
s.select(|_, sel| {
let mut now = if toggle { sel.slices.clone() } else { vec![] };
+2 -1
View File
@@ -65,7 +65,8 @@ pub(crate) fn enter(s: &mut Session, p: &Value) -> Result<Value> {
if st.doc.pattern_edit.is_some() {
return Err(bad(C, "finish editing the pattern first"));
}
let given = object_param(p, "id", C)?.map(|i| vec![i]).or_else(|| super::ids_param(p, "ids").filter(|v| !v.is_empty()));
let ids = super::ids_param(p, "ids", C)?;
let given = object_param(p, "id", C)?.map(|i| vec![i]).or_else(|| ids.filter(|v| !v.is_empty()));
let (name, xf, instance) = match given {
Some(ids) => {
let symbols = ids.iter().map(|id| instance_of(&st.doc, *id)).collect::<Option<Vec<_>>>();
+9 -2
View File
@@ -1068,7 +1068,7 @@ fn set_range_style(s: &mut Session, p: &Value) -> Result<Value> {
if sets_font {
// Empty text has one run, which the empty range styles. Characters that kept their font aren't the command's.
let styled = if a == b { t.runs.clone() } else { unprotected_runs(&t.runs, a, b, &kept) };
set.extend(styled.iter().map(|r| super::fonts::font_of(&r.style)));
set.extend(styled.iter().flat_map(super::fonts::run_fonts));
}
refresh_bounds(t);
n = if *f == id { t.runs.len() } else { n };
@@ -1140,7 +1140,12 @@ fn glyph_target(t: &TextObject, p: &Value, c: &str) -> Result<(char, std::ops::R
return Err(bad(c, "an inline graphic has no glyphs"));
}
let db = vectorcraft_text::FontDb::global();
let face = db.style_face(&run.style).filter(|_| db.has_family(&run.style.font_family));
let face = match db.composite_resolve_at(&run.style.font_family, &run.text, start - at) {
// Type in a composite font: the character is drawn in the font of the part its
// grapheme cluster falls in.
Some((face, m)) => (m != vectorcraft_text::FontMatch::Missing).then_some(face),
None => db.style_face(&run.style).filter(|_| db.has_family(&run.style.font_family)),
};
let face = face.ok_or_else(|| bad(c, format!("the font {} is not available", run.style.font_family)))?;
return Ok((ch, start..start + ch.len_utf8(), face));
}
@@ -1602,6 +1607,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
system_layout: false,
fractional_widths: true,
list_count: 0,
list_level_counts: Vec::new(),
cached_bounds: None,
cached_baselines: Vec::new(),
};
@@ -1890,6 +1896,7 @@ fn headline_tracking(t: &TextObject, target: f64) -> Option<f64> {
system_layout: t.system_layout,
fractional_widths: t.fractional_widths,
list_count: 0,
list_level_counts: Vec::new(),
cached_bounds: None,
cached_baselines: Vec::new(),
};
+1 -1
View File
@@ -327,7 +327,7 @@ fn font_change(before: &CharStyle, mut st: CharStyle, attrs: &Map<String, Value>
/// Change text in layers, symbols, graph designs, pattern tiles, saved pattern tiles restored by
/// Cancel, and brush art, including opacity masks in those trees.
fn each_text_mut(d: &mut Document, f: &mut impl FnMut(&mut vectorcraft_doc::TextObject) -> Result<()>) -> Result<()> {
pub(crate) fn each_text_mut(d: &mut Document, f: &mut impl FnMut(&mut vectorcraft_doc::TextObject) -> Result<()>) -> Result<()> {
fn has_text(n: &Node) -> bool {
matches!(n.kind, NodeKind::Text(_))
|| n.extras().mask.as_ref().is_some_and(|mask| has_text(&mask.art))
+12 -8
View File
@@ -195,6 +195,7 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) {
|| optical != t.optical_margin
|| vertical != t.vertical
|| part.list_count != t.list_count
|| part.list_level_counts != t.list_level_counts
|| continues != t.first_para_continues
{
t.runs = next.runs;
@@ -203,6 +204,7 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) {
t.optical_margin = optical;
t.vertical = vertical;
t.list_count = part.list_count;
t.list_level_counts = part.list_level_counts;
t.first_para_continues = continues;
refresh_bounds(t);
}
@@ -216,14 +218,15 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) {
}
/// Type that is not a frame of a thread is a story of its own: its numbered list paragraphs count
/// from 1. Each such type object with another [`TextObject::list_count`] (a copy of a frame, a
/// frame taken out of its thread, a frame converted to point type, which [`reflow`] takes out of
/// its thread) gets 0 and is laid out again.
/// from 1. Each such type object with another [`TextObject::list_count`] or
/// [`TextObject::list_level_counts`] (a copy of a frame, a frame taken out of its thread, a frame
/// converted to point type, which [`reflow`] takes out of its thread) gets 0 at every level and is
/// laid out again.
pub(crate) fn restart_unthreaded_counts(doc: &mut std::sync::Arc<Document>) {
let mut stale = vec![];
doc.walk(|n| {
if let NodeKind::Text(t) = &n.kind
&& t.list_count != 0
&& (t.list_count != 0 || !t.list_level_counts.is_empty())
{
stale.push(n.id);
}
@@ -240,6 +243,7 @@ pub(crate) fn restart_unthreaded_counts(doc: &mut std::sync::Arc<Document>) {
for id in stale {
if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) {
t.list_count = 0;
t.list_level_counts.clear();
refresh_bounds(t);
}
}
@@ -444,13 +448,13 @@ fn cut_to_shown(n: &mut Node, ids: &[NodeId]) {
{
// Split as `take_overflow` splits it: the first part is what the frame shows.
let from: &TextObject = t;
let parts = vectorcraft_text::thread::distribute_continuing(
let parts = vectorcraft_text::thread::distribute_continuing_levels(
vectorcraft_text::FontDb::global(),
&[from, from],
&from.runs,
&from.paragraph_styles(),
from.first_para_continues,
from.list_count,
from.list_counts(),
);
if let (Some(head), Some(_)) = (parts.first(), parts.get(1).filter(|p| p.runs.iter().any(|r| !r.text.is_empty()))) {
t.runs = head.runs.clone();
@@ -485,13 +489,13 @@ fn take_overflow(d: &mut Document, to: NodeId, from: &TextObject) {
// `from`'s text split as if it flowed on into a second frame: the second part is what `from`
// has no room for. Its share is measured as it is laid out, continuing a paragraph or not and
// with its numbered list paragraphs counted on from the frames before it.
let parts = vectorcraft_text::thread::distribute_continuing(
let parts = vectorcraft_text::thread::distribute_continuing_levels(
db,
&[from, from],
&from.runs,
&from.paragraph_styles(),
from.first_para_continues,
from.list_count,
from.list_counts(),
);
let Some(rest) = parts.get(1).filter(|p| p.runs.iter().any(|r| !r.text.is_empty())) else { return };
let mut tail = from.clone();
+103
View File
@@ -981,6 +981,74 @@ fn outlining_a_numbered_last_frame_keeps_only_the_text_it_has_no_room_for() {
assert_eq!(text(&s, a), format!("{}{hidden}", shown[0]));
}
/// As above for a list at level 2: the last frame's numbered paragraphs are counted on from the
/// count the frames before it reached at that level.
#[test]
fn outlining_a_last_frame_numbered_at_a_deeper_level_keeps_only_the_text_it_has_no_room_for() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
let items: String = (1..=7).map(|i| format!("Item {i}\n")).collect();
let story = format!("{items}{}", TEXT.repeat(4));
let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "area": {"width": 150, "height": 106}})).unwrap());
let c = new_id(s.execute("text.create", &json!({"x": 200, "y": 10, "text": "", "area": {"width": 150, "height": 20}})).unwrap());
s.execute("text.thread.add", &json!({"from": a, "to": c})).unwrap();
s.execute("text.setFormat", &json!({"ids": [a, c], "number": "upperRoman", "listLevel": 2, "leftIndent": 14, "firstLineIndent": -14})).unwrap();
let shown = [a, c].map(|id| text(&s, id));
assert!(shown[0].ends_with("Item 7\n") && shown[1].starts_with("The quick"), "{shown:?}");
let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(c)).cloned() else { panic!("not area type") };
assert_eq!((t.list_count, t.list_level_counts.as_slice()), (0, &[7][..]));
let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
assert_eq!(laid.markers.first().map(|m| m.text.as_str()), Some("VIII."));
assert!(laid.overflow, "the last frame has more text than it shows");
let end = laid.lines.last().map_or(0, |l| l.end);
let from_one = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &TextObject { list_level_counts: vec![], ..t });
assert_ne!(from_one.lines.last().map_or(0, |l| l.end), end, "counted from 1, the frame would show other text");
let hidden = shown[1][end..].trim_start_matches(' ');
s.execute("select.set", &json!({"ids": [c]})).unwrap();
s.execute("type.createOutlines", &json!({})).unwrap();
assert_eq!(text(&s, a), format!("{}{hidden}", shown[0]));
}
/// New Symbol and Redefine Symbol on a thread's last frame numbered at level 2: the symbol's
/// art holds the text the frame shows, measured with the count the frames before it reached at
/// that level, and the frame before it takes the rest.
#[test]
fn a_symbol_of_a_last_frame_numbered_at_a_deeper_level_holds_the_text_it_shows() {
for command in ["symbol.new", "symbol.update"] {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
let items: String = (1..=7).map(|i| format!("Item {i}\n")).collect();
let story = format!("{items}{}", TEXT.repeat(4));
let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "area": {"width": 150, "height": 106}})).unwrap());
let c = new_id(s.execute("text.create", &json!({"x": 200, "y": 10, "text": "", "area": {"width": 150, "height": 20}})).unwrap());
s.execute("text.thread.add", &json!({"from": a, "to": c})).unwrap();
s.execute("text.setFormat", &json!({"ids": [a, c], "number": "upperRoman", "listLevel": 2, "leftIndent": 14, "firstLineIndent": -14}))
.unwrap();
let shown = [a, c].map(|id| text(&s, id));
assert!(shown[0].ends_with("Item 7\n") && shown[1].starts_with("The quick"), "{command}: {shown:?}");
let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(c)).cloned() else { panic!("not area type") };
let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
assert!(laid.overflow, "{command}: the last frame has more text than it shows");
let end = laid.lines.last().map_or(0, |l| l.end);
let from_one = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &TextObject { list_level_counts: vec![], ..t });
assert_ne!(from_one.lines.last().map_or(0, |l| l.end), end, "{command}: counted from 1, the frame would show other text");
let hidden = shown[1][end..].trim_start_matches(' ');
let name = if command == "symbol.update" {
let mark = new_id(s.execute("shape.rectangle", &json!({"x": 400, "y": 200, "width": 20, "height": 20})).unwrap());
s.execute("select.set", &json!({"ids": [mark]})).unwrap();
s.execute("symbol.new", &json!({"name": "Mark"})).unwrap();
"Mark"
} else {
"Story"
};
s.execute("select.set", &json!({"ids": [c]})).unwrap();
s.execute(command, &json!({"name": name})).unwrap();
let held = symbol_texts(&s, name);
assert_eq!(held.iter().map(|t| t.trim_end()).collect::<Vec<_>>(), [shown[1][..end].trim_end()], "{command}: the symbol's art");
assert_eq!(text(&s, a), format!("{}{hidden}", shown[0]), "{command}: the frame before it takes the rest");
}
}
/// A story of one-line paragraphs that each start with a quotation mark, threaded from area
/// type `a` into the rectangle `b`, which shows the paragraphs that `a` has no room for.
fn quoted_thread() -> (Session, u64, u64) {
@@ -1325,6 +1393,41 @@ fn numbered_list_paragraphs_count_on_through_a_thread() {
assert_eq!(numbers(&s, b), all(1), "the frame left holds the story");
}
/// List levels in a thread: a deeper level's count runs on into the next frame, which saves
/// it (`listLevelCounts`), and a pasted copy of that frame counts every level from 1.
#[test]
fn list_levels_count_on_through_a_thread() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
let items = (1..=10).map(|i| format!("Item {i}")).collect::<Vec<_>>().join("\n");
let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": items, "area": {"width": 150, "height": 60}})).unwrap());
let b = new_id(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap());
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
s.execute("text.thread.create", &json!({})).unwrap();
s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number", "listLevel": 2})).unwrap();
s.execute("text.setFormat", &json!({"ids": [a], "listLevel": 1, "start": 0, "end": 0})).unwrap();
let frame = |s: &Session, id: u64| text_of(&s.doc().unwrap().doc, NodeId(id)).cloned().unwrap();
let numbers =
|t: &TextObject| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).markers.iter().map(|m| m.text.clone()).collect::<Vec<_>>();
let first = numbers(&frame(&s, a));
assert!((2..10).contains(&first.len()), "both frames hold items: {first:?}");
let expected: Vec<String> = std::iter::once("1.".to_string()).chain((1..=9).map(|n| format!("{n}."))).collect();
assert_eq!([first.clone(), numbers(&frame(&s, b))].concat(), expected);
let b_counts = frame(&s, b).list_counts();
assert_eq!(b_counts.parts(), (1, vec![first.len() as u32 - 1]));
let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
let Some(NodeKind::Text(back)) = reopened.node(NodeId(b)).map(|n| &n.kind) else { panic!("text") };
assert_eq!((back.list_counts(), numbers(back)), (b_counts, numbers(&frame(&s, b))), "reopened");
s.execute("select.set", &json!({"ids": [b]})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
s.execute("edit.paste", &json!({})).unwrap();
let copy = s.doc().unwrap().selection.objects.first().copied().unwrap();
assert_ne!(copy, NodeId(b));
let pasted = frame(&s, copy.0);
assert_eq!((pasted.list_count, pasted.list_level_counts.as_slice()), (0, &[][..]));
assert_eq!(numbers(&pasted).first().map(String::as_str), Some("1."), "a pasted copy counts from 1");
}
/// New Symbol from a numbered thread keeps each frame's count in the symbol's art, so the art
/// looks as it did; Break Link puts the frames in no thread, and each counts from 1.
#[test]
+2 -2
View File
@@ -919,7 +919,7 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
super::textedit::unprotected_runs(&t.runs, a, b, &kept)
}
};
set.extend(styled.iter().map(|r| super::fonts::font_of(&r.style)));
set.extend(styled.iter().flat_map(super::fonts::run_fonts));
}
if let Some(j) = justify {
let span = para_span(range, t);
@@ -1279,7 +1279,7 @@ fn headline_fit(t: &TextObject, tracking: f64) -> Option<(f64, f64, usize)> {
let cell = lay.frames.first()?;
let line = lay.lines.first()?;
let para = t.para_at(0);
let avail = cell.width() - 2.0 * t.area.inset - para.left_indent - para.right_indent;
let avail = cell.width() - 2.0 * t.area.inset - para.laid_out_left_indent() - para.right_indent;
Some((line.x1 - line.x0, avail, line.end))
}
+244 -5
View File
@@ -437,7 +437,7 @@ fn bulleted_lists_are_set_per_paragraph_saved_and_continued() {
assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["list"], json!({"bullet": "square"}));
// A saved list reads the fields it leaves out as their defaults and ignores those it doesn't know.
let read = |v: serde_json::Value| serde_json::from_value::<vectorcraft_doc::ParaStyle>(json!({ "list": v })).unwrap().list;
assert_eq!((read(json!({})), read(json!({"bullet": "dash", "level": 2}))), (dot, Some(ParaList::bulleted(Bullet::Dash))));
assert_eq!((read(json!({})), read(json!({"bullet": "dash", "restart": 2}))), (dot, Some(ParaList::bulleted(Bullet::Dash))));
let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
let Some(NodeKind::Text(back)) = reopened.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") };
assert_eq!((back.para_at(0).list, back.para_at(1).list, back.para_at(2).list), (square, dot, None));
@@ -500,7 +500,7 @@ fn numbered_lists_are_set_per_paragraph_saved_and_styled() {
s.execute("text.setFormat", &json!({"bullet": "square", "start": 0, "end": 0})).unwrap();
assert_eq!(list(&s, 0), Some(ParaList::bulleted(Bullet::Square)));
s.execute("text.setFormat", &json!({"number": "lowerLetters", "start": 0, "end": 0})).unwrap();
let letters = Some(ParaList { bullet: Bullet::Square, number: Some(NumberStyle::LowerLetters) });
let letters = Some(ParaList { bullet: Bullet::Square, number: Some(NumberStyle::LowerLetters), ..ParaList::default() });
assert_eq!(list(&s, 0), letters);
assert_eq!(numbers(&s), ["a.", "2.", "3."]);
assert_eq!((text(&s).para_at(0).left_indent, text(&s).para_at(0).first_line_indent), (18.0, -18.0));
@@ -536,6 +536,70 @@ fn numbered_lists_are_set_per_paragraph_saved_and_styled() {
assert_eq!((list(&s, 0), list(&s, 1), list(&s, 2)), (None, None, None));
}
/// Bullets and Numbering… › Alignment: `text.setFormat {listAlign}` sets where the marker
/// stands in the list paragraphs it touches, in one undo step, and refuses unknown values;
/// a paragraph in no list gets no list from it. A list keeps its alignment when it changes
/// between bullets and numbers, and a call that puts a paragraph in a list gives it the
/// alignment too. The alignment is saved only when it is right, opens with the file, and
/// paragraph styles carry it both ways.
#[test]
fn list_markers_are_aligned_per_paragraph_saved_and_styled() {
use vectorcraft_doc::text::{Bullet, ListAlign, NumberStyle, ParaList};
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "One\nTwo\nThree", "area": {"width": 300, "height": 200}})).unwrap()["id"]
.as_u64()
.unwrap();
let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => (**t).clone(),
_ => panic!("text"),
};
let list = |s: &Session, i: usize| text(s).para_at(i).list;
let align = |s: &Session| (0..3).map(|i| list(s, i).map(|l| l.align)).collect::<Vec<_>>();
let (left, right) = (Some(ListAlign::Left), Some(ListAlign::Right));
s.execute("select.set", &json!({"ids": [id]})).unwrap();
s.execute("text.setFormat", &json!({"list": "number", "start": 0, "end": 4})).unwrap();
assert_eq!(align(&s), [left, left, None]);
let undo = s.doc().unwrap().history.undo.len();
s.execute("text.setFormat", &json!({"listAlign": "right", "start": 0, "end": 0})).unwrap();
assert_eq!(align(&s), [right, left, None]);
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(align(&s), [left, left, None]);
s.execute("text.setFormat", &json!({"listAlign": "right"})).unwrap();
assert_eq!(align(&s), [right, right, None], "the paragraph in no list stays out of one");
for bad in [json!({"listAlign": "center"}), json!({"listAlign": "Right"}), json!({"listAlign": 1}), json!({"listAlign": true})] {
assert!(s.execute("text.setFormat", &bad).is_err(), "{bad}");
}
assert_eq!(align(&s), [right, right, None], "an unknown value changes nothing");
// Bullets and numbers keep it; a call that makes a list sets it there too.
s.execute("text.setFormat", &json!({"bullet": "square", "start": 0, "end": 0})).unwrap();
assert_eq!(list(&s, 0), Some(ParaList::bulleted(Bullet::Square).aligned(ListAlign::Right)));
s.execute("text.setFormat", &json!({"list": "bullet", "listAlign": "right", "start": 8, "end": 8})).unwrap();
assert_eq!(list(&s, 2), Some(ParaList::bulleted(Bullet::Dot).aligned(ListAlign::Right)));
s.execute("text.setFormat", &json!({"listAlign": "left", "start": 8, "end": 8})).unwrap();
assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["list"], json!({"bullet": "square", "align": "right"}));
assert_eq!(serde_json::to_value(text(&s).para_at(2)).unwrap()["list"], json!({"bullet": "dot"}));
let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
let Some(NodeKind::Text(back)) = reopened.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") };
assert_eq!((back.para_at(0).list, back.para_at(1).list), (list(&s, 0), Some(ParaList::numbered(NumberStyle::Arabic).aligned(ListAlign::Right))));
// A list turned off and on again starts aligned left.
s.execute("text.setFormat", &json!({"list": "none", "start": 4, "end": 4})).unwrap();
s.execute("text.setFormat", &json!({"list": "number", "start": 4, "end": 4})).unwrap();
assert_eq!(align(&s)[1], left);
// A style captured from a list aligned right gives other lists that alignment, and one
// captured from a list aligned left takes it back.
let other = s.execute("text.create", &json!({"x": 10, "y": 300, "text": "Other"})).unwrap()["id"].as_u64().unwrap();
s.execute("text.setFormat", &json!({"ids": [other], "list": "number"})).unwrap();
s.execute("paraStyle.new", &json!({"name": "Aligned left", "id": other})).unwrap();
s.execute("paraStyle.new", &json!({"name": "Aligned right", "id": id})).unwrap();
s.execute("paraStyle.apply", &json!({"name": "Aligned right", "id": other})).unwrap();
let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(other)).map(|n| &n.kind) else { panic!("text") };
assert_eq!(t.para_at(0).list.map(|l| l.align), right);
s.execute("paraStyle.apply", &json!({"name": "Aligned left", "id": id})).unwrap();
assert_eq!(align(&s), [left, left, left]);
}
/// The text object `id`.
fn text_at(s: &Session, id: u64) -> vectorcraft_doc::text::TextObject {
match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
@@ -724,6 +788,56 @@ fn point_and_vertical_type_convert(s: &mut Session) {
assert_eq!(s.doc().unwrap().history.undo.len(), steps);
}
/// Convert to Text of a list whose markers are aligned right writes the same characters as for
/// a list aligned left, the marker and then a tab, and gives the paragraphs the same tab stops:
/// the text after the tab stays where it was, and the marker moves to the first line's start,
/// where a list aligned left draws it. Numbers narrower than the hanging indent (their tab went
/// to the left indent) and wider (their tab went to the next default stop), and bullets.
#[test]
fn convert_to_text_of_markers_aligned_right_keeps_the_text_in_place() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let text_glyphs = |s: &Session, id: u64| {
let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") };
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let milli = |v: f64| (v * 1000.0).round() as i64;
lay.glyphs
.iter()
.filter(|g| g.len > 0 && !g.outline.elements().is_empty())
.map(|g| (g.gid, milli(g.origin.x), milli(g.origin.y)))
.collect::<Vec<_>>()
};
let plain = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => t.plain_text(),
_ => panic!("text"),
};
let items = (1..=10).map(|i| format!("Item {i}")).collect::<Vec<_>>().join("\n");
for (list, converted) in [
("number", (1..=10).map(|i| format!("{i}.\tItem {i}")).collect::<Vec<_>>().join("\n")),
("bullet", (1..=10).map(|i| format!("\u{2022}\tItem {i}")).collect::<Vec<_>>().join("\n")),
] {
let make = |s: &mut Session, align: &str| {
let id = s.execute("text.create", &json!({"x": 300, "y": 10, "size": 20, "text": items, "area": {"width": 300, "height": 600}})).unwrap()
["id"]
.as_u64()
.unwrap();
s.execute("text.setFormat", &json!({"ids": [id], "list": list, "listAlign": align})).unwrap();
id
};
let (left, right) = (make(&mut s, "left"), make(&mut s, "right"));
let drawn_left = drawn_glyphs(&s, left);
let text_before = text_glyphs(&s, right);
assert_eq!(text_before, text_glyphs(&s, left), "{list}: the text starts at the same place with either alignment");
assert_ne!(drawn_glyphs(&s, right), drawn_left, "{list}: the markers aligned right are drawn elsewhere");
s.execute("text.convertListToText", &json!({"ids": [left, right]})).unwrap();
assert_eq!((plain(&s, left), plain(&s, right)), (converted.clone(), converted), "{list}: the marker, then a tab");
let after = text_glyphs(&s, right);
assert!(text_before.iter().all(|g| after.contains(g)), "{list}: the text stays where it was");
assert_eq!(drawn_glyphs(&s, right), drawn_left, "{list}: the markers start where a list aligned left draws them");
assert_eq!(drawn_glyphs(&s, left), drawn_left, "{list}: aligned left, every glyph stays");
}
}
/// A right-to-left list paragraph converted to text starts with the number, its period and a
/// tab, which are read first: the line draws its glyphs in the order it drew them with the
/// marker, the number at the right with its period at its left. Letters and roman numerals
@@ -907,6 +1021,130 @@ fn convert_to_text_moves_the_type_tools_selection() {
assert_eq!(s.active().unwrap().selection.objects, vec![NodeId(a)], "the first frame is selected");
}
/// Convert to Text of list paragraphs at deeper levels writes each level's own numbers, those
/// a thread's earlier frames counted at each level included, and every glyph is drawn where it
/// was: each converted paragraph's left indent takes its level's indent, with the plain left
/// stop there.
#[test]
fn convert_to_text_keeps_deeper_list_levels_in_place() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let text = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => (**t).clone(),
_ => panic!("text"),
};
let numbers = |s: &Session, id: u64| {
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(s, id)).markers.iter().map(|m| m.text.clone()).collect::<Vec<_>>()
};
let text_in = "One\nTwo\nThree\nFour\nFive";
let id = s.execute("text.create", &json!({"x": 10, "y": 10, "size": 20, "text": text_in, "area": {"width": 400, "height": 400}})).unwrap()["id"]
.as_u64()
.unwrap();
s.execute("text.setFormat", &json!({"ids": [id], "list": "number"})).unwrap();
s.execute("text.setFormat", &json!({"ids": [id], "listLevel": 2, "start": 4, "end": 9})).unwrap();
s.execute("text.setFormat", &json!({"ids": [id], "listLevel": 3, "start": 14, "end": 14})).unwrap();
let before = drawn_glyphs(&s, id);
s.execute("text.convertListToText", &json!({"ids": [id]})).unwrap();
assert_eq!(drawn_glyphs(&s, id), before, "every glyph is drawn where it was");
let t = text(&s, id);
assert_eq!(t.plain_text(), "1.\tOne\n1.\tTwo\n2.\tThree\n1.\tFour\n2.\tFive");
let paras = (0..5)
.map(|k| {
let p = t.para_at(k);
(p.list.is_none(), p.left_indent, p.tabs.iter().map(|tab| tab.position).collect::<Vec<_>>())
})
.collect::<Vec<_>>();
assert_eq!(paras, [18.0, 36.0, 36.0, 54.0, 18.0].map(|x| (true, x, vec![x])), "out of the list, at the level's indent");
// A thread whose second frame goes on with the numbers of level 2.
let items = std::iter::once("Head".to_string()).chain((1..=9).map(|i| format!("Item {i}"))).collect::<Vec<_>>().join("\n");
let a =
s.execute("text.create", &json!({"x": 10, "y": 500, "text": items, "area": {"width": 150, "height": 60}})).unwrap()["id"].as_u64().unwrap();
let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 500, "width": 150, "height": 200})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
s.execute("text.thread.create", &json!({})).unwrap();
s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number", "listLevel": 2})).unwrap();
s.execute("text.setFormat", &json!({"ids": [a], "listLevel": 1, "start": 0, "end": 0})).unwrap();
let first = numbers(&s, a).len();
assert!((3..10).contains(&first) && text(&s, a).plain_text().ends_with('\n'), "both frames hold items: {first}");
assert_eq!(numbers(&s, b).first(), Some(&format!("{first}.")), "the second frame counts level 2 on");
let before = (drawn_glyphs(&s, a), drawn_glyphs(&s, b));
s.execute("text.convertListToText", &json!({"ids": [a, b]})).unwrap();
assert_eq!((drawn_glyphs(&s, a), drawn_glyphs(&s, b)), before, "both frames");
let story = std::iter::once("1.\tHead".to_string()).chain((1..=9).map(|i| format!("{i}.\tItem {i}"))).collect::<Vec<_>>().join("\n");
assert_eq!(text(&s, a).plain_text() + &text(&s, b).plain_text(), story);
}
/// List levels: `text.setFormat {listLevel}` sets the level of the list paragraphs it touches
/// as one undo step, from 1 to 9, and refuses other values; bullets and numbers keep it, a
/// list turned off and on starts at level 1 again, each level counts its own numbers, and the
/// level is saved past level 1, reopened and carried by paragraph styles.
#[test]
fn list_levels_are_set_per_paragraph_saved_and_styled() {
use vectorcraft_doc::text::{Bullet, NumberStyle, ParaList};
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let id =
s.execute("text.create", &json!({"x": 10, "y": 10, "text": "One\nTwo\nThree\nFour", "area": {"width": 300, "height": 200}})).unwrap()["id"]
.as_u64()
.unwrap();
let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => (**t).clone(),
_ => panic!("text"),
};
let levels = |s: &Session| (0..4).map(|i| text(s).para_at(i).list.map(|l| l.level)).collect::<Vec<_>>();
let numbers = |s: &Session| {
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(s)).markers.iter().map(|m| m.text.clone()).collect::<Vec<_>>()
};
s.execute("select.set", &json!({"ids": [id]})).unwrap();
s.execute("text.setFormat", &json!({"list": "number", "start": 0, "end": 9})).unwrap();
assert_eq!(levels(&s), [Some(1), Some(1), Some(1), None]);
let undo = s.doc().unwrap().history.undo.len();
s.execute("text.setFormat", &json!({"listLevel": 2, "start": 4, "end": 9})).unwrap();
assert_eq!(levels(&s), [Some(1), Some(2), Some(2), None]);
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
assert_eq!(numbers(&s), ["1.", "1.", "2."]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(levels(&s), [Some(1), Some(1), Some(1), None]);
s.execute("text.setFormat", &json!({"listLevel": 2.0, "start": 4, "end": 4})).unwrap();
assert_eq!(levels(&s)[1], Some(2));
s.execute("text.setFormat", &json!({"listLevel": 9})).unwrap();
assert_eq!(levels(&s), [Some(9), Some(9), Some(9), None], "the paragraph in no list stays out of one");
let bad = [json!({"listLevel": 0}), json!({"listLevel": 10}), json!({"listLevel": 1.5}), json!({"listLevel": "2"}), json!({"listLevel": true})];
for p in bad {
assert!(s.execute("text.setFormat", &p).is_err(), "{p}");
}
let err = s.execute("text.setFormat", &json!({"listLevel": 10})).unwrap_err().to_string();
assert!(err.contains("`listLevel` must be a whole number between 1 and 9 (got 10)"), "{err}");
assert_eq!(levels(&s)[0], Some(9), "an unknown value changes nothing");
// A deeper paragraph counts from 1, and one back at a shallower level goes on.
s.execute("text.setFormat", &json!({"listLevel": 1, "start": 0, "end": 0})).unwrap();
s.execute("text.setFormat", &json!({"listLevel": 3, "start": 4, "end": 4})).unwrap();
s.execute("text.setFormat", &json!({"listLevel": 1, "start": 8, "end": 8})).unwrap();
assert_eq!(numbers(&s), ["1.", "1.", "2."]);
// Bullets and numbers keep the level; a call that makes a list sets it there too.
s.execute("text.setFormat", &json!({"bullet": "square", "start": 4, "end": 4})).unwrap();
assert_eq!(text(&s).para_at(1).list, Some(ParaList::bulleted(Bullet::Square).at_level(3)));
s.execute("text.setFormat", &json!({"list": "bullet", "listLevel": 4, "start": 14, "end": 14})).unwrap();
assert_eq!(text(&s).para_at(3).list, Some(ParaList::bulleted(Bullet::Dot).at_level(4)));
assert_eq!(serde_json::to_value(text(&s).para_at(1)).unwrap()["list"], json!({"bullet": "square", "level": 3}));
assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["list"], json!({"bullet": "dot", "number": "arabic"}));
let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
let Some(NodeKind::Text(back)) = reopened.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") };
assert_eq!((0..4).map(|i| back.para_at(i).list).collect::<Vec<_>>(), (0..4).map(|i| text(&s).para_at(i).list).collect::<Vec<_>>());
// A list turned off and on again starts at level 1.
s.execute("text.setFormat", &json!({"list": "none", "start": 4, "end": 4})).unwrap();
s.execute("text.setFormat", &json!({"list": "number", "start": 4, "end": 4})).unwrap();
assert_eq!(text(&s).para_at(1).list, Some(ParaList::numbered(NumberStyle::Arabic)));
// A style captured from a paragraph at level 4 sets that level, and one captured at level 1
// takes it back.
s.execute("paraStyle.new", &json!({"name": "Level 4", "id": id, "start": 14, "end": 14})).unwrap();
s.execute("paraStyle.new", &json!({"name": "Level 1", "id": id, "start": 0, "end": 0})).unwrap();
s.execute("paraStyle.apply", &json!({"name": "Level 4", "id": id, "start": 0, "end": 0})).unwrap();
assert_eq!(levels(&s)[0], Some(4));
s.execute("paraStyle.apply", &json!({"name": "Level 1", "id": id})).unwrap();
assert_eq!(levels(&s), [Some(1); 4]);
}
/// Type › Create Outlines makes a path of a list item's bullet as well as of its characters.
#[test]
fn create_outlines_makes_a_path_of_the_bullet() {
@@ -1627,9 +1865,10 @@ fn variable_font_settings_are_a_character_attribute_saved_only_when_set() {
s.execute("text.setStyle", &json!({"style": "Bold"})).unwrap();
assert!(runs(&s).iter().all(|r| r.style.font_axes.is_empty()));
s.execute("text.setFormat", &json!({"fontAxes": {"wdth": 75}})).unwrap();
s.execute("text.replaceFont", &json!({"from": {"family": FAMILY}, "to": {"family": "Inter"}})).unwrap();
s.execute("text.replaceFont", &json!({"from": {"family": FAMILY}, "to": {"family": "Source Sans 3"}})).unwrap();
assert!(runs(&s).iter().all(|r| r.style.font_axes.is_empty()));
// A static font has no axes to set.
// A static font has no axes to set (Source Sans 3, not Inter: an installed Inter
// Variable would shadow the bundled static and give it axes).
s.execute("text.setFormat", &json!({"fontAxes": {"wght": 650}})).unwrap();
assert_eq!(saved(&s), None);
// A missing font keeps them as given, for when it is installed.
@@ -1651,7 +1890,7 @@ fn variable_font_settings_are_a_character_attribute_saved_only_when_set() {
{"tag": "wdth", "name": "Width", "min": 75.0, "default": 100.0, "max": 100.0, "value": 100.0, "hidden": false},
])
);
assert_eq!(s.execute("text.fontList", &json!({"family": "Inter"})).unwrap()["axes"], json!([]));
assert_eq!(s.execute("text.fontList", &json!({"family": "Source Sans 3"})).unwrap()["axes"], json!([]));
}
#[test]
+23 -3
View File
@@ -56,7 +56,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Type on a Path Options…",
["Type", "Type on a Path"],
None,
"{ids?|id?, start?: 0..1, end?: 0..1|null, flip?: bool, effect?: rainbow|skew|3dRibbon|stairStep|gravity, alignToPath?: ascender|descender|center|baseline, spacing?: pt} set the selected type on a path's options: start and end place its start and end brackets as fractions of the path's length (end null: the end of the path, or once round a closed path), and flip then turns the type to the other side of its path (the path runs the other way, the brackets swap ends); none given: query → the first object's {start, end, flip: false, effect, alignToPath, spacing}",
"{ids?|id?, start?: 0..1, end?: 0..1|null, flip?: bool, effect?: rainbow|skew|3dRibbon|stairStep|gravity, alignToPath?: ascender|descender|center|baseline, spacing?: pt} set the selected type on a path's options: start and end place its start and end brackets as fractions of the path's length (end null: the end of the path, or once round a closed path), and flip then turns the type to the other side of its path (the path runs the other way, the brackets swap ends); alignToPath runs each character's em box top (ascender), font bounding box bottom (descender), half the height of its H's top above the baseline (center) or baseline along the path, by the character's own font, size and vertical scale (vertical type: half an em either way, or none for center); none given: query → the first object's {start, end, flip: false, effect, alignToPath, spacing}",
has_selection,
path_options
),
@@ -373,8 +373,10 @@ fn to_point(s: &mut Session, p: &Value) -> Result<Value> {
}
// The point origin sits where the alignment anchors the first line.
let p0 = t.para_at(0);
let (x0, x1) =
lay.lines.first().map_or((fb.x0, fb.x1), |l| (l.avail.0 - p0.left_indent - p0.first_line_indent, l.avail.1 + p0.right_indent));
let (x0, x1) = lay
.lines
.first()
.map_or((fb.x0, fb.x1), |l| (l.avail.0 - p0.laid_out_left_indent() - p0.first_line_indent, l.avail.1 + p0.right_indent));
let ox = match p0.justify {
Justify::Center | Justify::JustifyCenter => (x0 + x1) * 0.5,
Justify::Right | Justify::JustifyRight => x1,
@@ -611,6 +613,9 @@ pub(crate) fn alt_code_char(code: u64) -> Option<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 +848,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('°'));
}
}
+3 -2
View File
@@ -121,13 +121,14 @@ pub(crate) const SELECTS_GIVEN_IDS: &[&str] = &[
/// The non-empty `ids` given to a command whose params description names `ids` as a key (the
/// key callers outside the app may pass it, [`cmd::params::takes_key`]), or its `id` as `ids` of
/// one when no `ids` are given and the description takes `id` so
/// ([`cmd::params::id_stands_for_ids`]), as [`cmd::targets`] reads them.
/// ([`cmd::params::id_stands_for_ids`]), as [`cmd::targets`] reads them. Entries of `ids` that are
/// not ids are left out of this list; `Session::execute` refuses them before it reads it.
pub(crate) fn given_ids(description: &'static str, params: &Value) -> Option<Vec<NodeId>> {
if !cmd::params::takes_key(description, "ids") {
return None;
}
match params.get("ids").filter(|v| !v.is_null()) {
Some(_) => cmd::ids_param(params, "ids"),
Some(v) => v.as_array().map(|a| a.iter().filter_map(Value::as_u64).map(NodeId).collect()),
None => cmd::id_param(params, "id").filter(|_| cmd::params::id_stands_for_ids(description)).map(|id| vec![id]),
}
.filter(|ids| !ids.is_empty())
+6
View File
@@ -95,6 +95,11 @@ pub struct DocState {
/// so an export or save to it asks first ([`cmd::fileio::check_not_lossy_overwrite`]). Not saved.
pub imported_from: Option<String>,
pub import_losses: Vec<String>,
/// The file its links' stored relative paths name places from: the file the document was read
/// from, also when that file is not its own (such as a `.ai` file another app wrote). A save
/// writes the saved file's relative paths and leaves the open document's links as they are.
/// `None`: the document's own file. Not saved ([`crate::cmd::links::LinkBase`]).
pub links_from: Option<String>,
/// Transform Again after a perspective move or scale (Perspective Selection tool): the
/// `perspective.transform` params it repeats. `None` once an ordinary transform follows.
pub last_perspective: Option<Value>,
@@ -296,6 +301,7 @@ impl DocState {
layers_open,
imported_from: None,
import_losses: vec![],
links_from: None,
last_perspective: None,
opened_by: file_access::current(),
empty_artboards: Default::default(),
+14
View File
@@ -62,6 +62,14 @@ pub struct Session {
/// While Cmd lends `tool` (a selection tool) for a drag: the tool it was lent to, which comes
/// back as it was at the release ([`Session::pointer`]).
pub(crate) lender: Option<Box<dyn Tool>>,
/// The selection of the active document (its uid) as Cmd lent `tool`: what the lent tool's
/// gesture found, so that one that leaves it as it was picks nothing.
pub(crate) selection_at_lend: Option<(u64, vectorcraft_doc::Selection)>,
/// The selection a lent tool's gesture changed and left as it gave `lender` back, in the
/// document (its uid) it was made in: while it is still that document's selection, the anchors
/// in it are ones picked, not the Pen's last anchor, and Copy and Cut take them with the Pen or
/// Curvature tool back too (#6123).
pub(crate) lent_selection: Option<(u64, vectorcraft_doc::Selection)>,
/// The selection tool chosen last (Selection, Direct Selection or Group Selection): the one Cmd
/// lends the other tools. None until one is chosen.
pub(crate) last_selection_tool: Option<&'static str>,
@@ -164,6 +172,9 @@ pub struct Session {
/// has inherited the selection's appearance: the documents ([`DocState::uid`]) whose newest
/// history step takes the colors the batch finally left.
pub(crate) batch_paint_docs: Vec<u64>,
/// The composite fonts (lowercase names) that the open documents defined when the font
/// database was last brought in step with them ([`cmd::compositefonts::sync`]).
pub(crate) composite_names: Vec<String>,
/// While a command that changes nothing in the document runs (a query, or one of
/// [`command_sets::JOURNALED_READS`]): it reads the instance of an object selected in a symbol
/// instance's art.
@@ -249,6 +260,9 @@ pub struct Session {
/// The Global Edit Options Start Global Edit matches with (`select.globalEditOptions`); not
/// saved.
pub global_edit_options: cmd::globaledit::Options,
/// Set while [`Session::tool_typed_text`] passes keyboard input to the active tool
/// ([`vectorcraft_tools::ToolContext::typed_from_keyboard`]).
keyboard_text: bool,
}
impl Default for Session {
+31 -4
View File
@@ -42,6 +42,8 @@ impl Session {
journal: Journal::default(),
tool: vectorcraft_tools::create("selection"),
lender: None,
selection_at_lend: None,
lent_selection: None,
last_selection_tool: None,
last_view: ViewInfo::default(),
preview_memo: Default::default(),
@@ -81,6 +83,7 @@ impl Session {
batch_stash: None,
batch_steps: None,
batch_paint_docs: Vec::new(),
composite_names: Vec::new(),
reading: false,
place_hand_off: None,
queue_hand_off: None,
@@ -110,6 +113,7 @@ impl Session {
link_stamps: Default::default(),
link_scan: None,
global_edit_options: Default::default(),
keyboard_text: false,
}
}
@@ -173,10 +177,16 @@ impl Session {
pub fn set_active(&mut self, index: usize) -> bool {
if index < self.docs.len() {
if self.active != Some(index) {
let switched = self.active != Some(index);
if switched {
self.reset_tool_for_doc_switch();
}
self.active = Some(index);
// Type is drawn in the composite fonts of the document now in front, and measured again
// when they differ from those of the document before.
if switched && cmd::compositefonts::sync(self) {
self.refresh_document_text_bounds(index);
}
true
} else {
false
@@ -193,7 +203,7 @@ impl Session {
format!("Untitled-{}", self.untitled_counter)
}
/// Text layout bounds are a cache (not saved): compute them for a document just read, or
/// Text layout bounds are a cache (not saved): compute them after its fonts are registered, or
/// selection boxes and hit testing would use the rough estimate until each text is edited, and
/// what is fitted to type's bounds (an envelope warping it) would come out differently. That
/// holds for the type in symbol definitions, pattern tiles, graph designs and opacity masks too,
@@ -208,6 +218,14 @@ impl Session {
}
}
/// [`Session::refresh_text_bounds`] for open document `index`, as a change that is not an
/// edit: a saved document stays saved, and its undo history keeps its documents as they are.
fn refresh_document_text_bounds(&mut self, index: usize) {
if let Some(st) = self.docs.get_mut(index) {
st.change_unsaved(|doc| Self::refresh_text_bounds(Arc::make_mut(doc)));
}
}
/// Add a document and make it active. Between `edit.undoGroup.begin` and `edit.undoGroup.end`,
/// an undo group with the begun group's name opens in it: here outside a command, and after the
/// command that adds it otherwise ([`Session::execute`]).
@@ -215,7 +233,7 @@ impl Session {
// Every edit is checked against the document before it, so the first one is within the cap
// (importers and the native reader have seen to theirs already, with a warning).
doc.limit_depth();
Self::refresh_text_bounds(&mut doc);
cmd::compositefonts::filter_loaded(&mut doc);
// Guides from files saved before guides had layers go on a layer.
doc.adopt_guides();
cmd::dimension::conform(&mut doc);
@@ -226,6 +244,8 @@ impl Session {
self.docs.push(st);
let i = self.docs.len() - 1;
self.active = Some(i);
cmd::compositefonts::sync(self);
self.refresh_document_text_bounds(i);
if self.depth == 0
&& let Some(label) = self.explicit_undo_group.clone()
{
@@ -241,7 +261,7 @@ impl Session {
return false;
}
doc.limit_depth();
Self::refresh_text_bounds(&mut doc);
cmd::compositefonts::filter_loaded(&mut doc);
doc.adopt_guides();
cmd::dimension::conform(&mut doc);
if self.active == Some(index) {
@@ -277,6 +297,12 @@ impl Session {
if let Some(stash) = &mut self.batch_stash {
stash.push(old);
}
// Type in the composite fonts of a document in the back is measured again when it comes to
// the front.
if self.active == Some(index) {
cmd::compositefonts::sync(self);
}
self.refresh_document_text_bounds(index);
true
}
@@ -293,6 +319,7 @@ impl Session {
stash.push(old);
}
self.active = if self.docs.is_empty() { None } else { Some(index.min(self.docs.len() - 1)) };
cmd::compositefonts::sync(self);
true
}
+15 -2
View File
@@ -15,11 +15,14 @@ impl Session {
r
}
/// Error, under the command's own id, at the first of `ids` that is malformed, names no node, or
/// names a layer or sublayer: a command in [`SELECTS_GIVEN_IDS`] acts on objects.
/// Error, under the command's own id, at the first of `ids` that is malformed, else at the first
/// that names no node or names a layer or sublayer: a command in [`SELECTS_GIVEN_IDS`] acts on
/// objects.
fn check_given_ids(&self, id: &str, params: &Value) -> Result<()> {
let (Some(st), Some(ids)) = (self.active(), params.get("ids")) else { return Ok(()) };
let Some(given) = ids.as_array() else { return cmd::checked_ids_param(self, params, "ids", id).map(drop) };
// A malformed entry anywhere in the list is refused first, under the key and the entry.
cmd::ids_param(params, "ids", id)?;
for value in given {
let node = cmd::checked_id(self, value, id)?;
if st.doc.node(node).is_some_and(|n| n.is_layer()) {
@@ -65,6 +68,13 @@ impl Session {
if SELECTS_GIVEN_IDS.contains(&id) {
self.check_given_ids(id, params)?;
}
// An `ids` list with an entry that is not an object id, or an `ids` that is not a list, is
// refused under the command's id before the menu check, which would otherwise run on the
// other entries or on the selection. This holds with no document open too, where commands
// such as `effect.list` still run.
if cmd::params::takes_key(spec.params, "ids") {
cmd::ids_param(params, "ids", id)?;
}
// Clear's menu needs a selection; an agent supplying explicit targets only needs a
// document. The command validates those targets before making any edit.
let given = given_ids(spec.params, params);
@@ -147,6 +157,9 @@ impl Session {
}
if let Some(open) = open {
self.open_undo_groups_in_new_documents(&open);
// Type is drawn in the composite fonts of the documents open now, an undone or opened
// document's included.
cmd::compositefonts::sync(self);
}
let journaled = spec.journal || (self.depth == 0 && std::mem::take(&mut self.journal_query));
if journaled && self.depth == 0 && self.active().is_none_or(|d| d.interaction.is_none()) {
+446 -15
View File
@@ -19,6 +19,11 @@
//! copied: it is loaded from where it is. `grants.json` holds what the user granted the plug-ins
//! stored there that list permissions (id, module hash, permissions); a stored plug-in whose
//! permissions it doesn't grant is skipped with a log record, and stays in the folder.
//! * **Plug-in settings and caches**: `storage/id-<id>/` in the plug-in store, the id written as
//! [`case_safe`] writes it, holds what a plug-in
//! keeps with the `settings` and `cache` permissions ([`PluginStorage`]): `settings.json` and a
//! `cache` folder with one file per entry. The host names every file; a plug-in only names keys.
//! Replacing a plug-in keeps them, and `plugin.remove` deletes them.
//!
//! The files are untrusted input like any other: sizes and counts are capped, a file that does not
//! parse is skipped with a log record, and every write is atomic.
@@ -26,9 +31,12 @@
use std::collections::{BTreeMap, BTreeSet};
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use vectorcraft_pdf::{PdfPreset, builtin_preset};
use vectorcraft_plugins::safe_mode;
use vectorcraft_plugins::storage::{self, Meter, Storage, StorageError};
use crate::Session;
use crate::cmd::pdfcmds::{PRESET_FORMAT, PresetFile};
@@ -55,6 +63,25 @@ pub const DISABLED_LOCK: &str = "disabled.lock";
const GRANTS_FILE: &str = "grants.json";
/// The largest grants file read.
const MAX_GRANTS_FILE: u64 = 1 << 20;
/// The folder in the plug-in store that holds each plug-in's settings and cache.
const PLUGIN_DATA_DIR: &str = "storage";
/// A plug-in's settings file, in its folder.
const SETTINGS_FILE: &str = "settings.json";
/// Where a settings file that doesn't read is moved, replacing the one moved there before.
const BAD_SETTINGS_FILE: &str = "settings.unreadable.json";
/// The folder of a plug-in's cache entries, in its folder.
const CACHE_DIR: &str = "cache";
/// The extension of a cache entry's file.
const CACHE_EXT: &str = "bin";
/// Items in a cache folder a write looks at, at most: the entries, and temporary files a write left.
/// A write to a folder that holds more is refused as full.
pub(crate) const MAX_CACHE_SCAN: usize = storage::MAX_CACHE_ENTRIES * 2;
/// Held while a plug-in's settings or cache change, so two runs can't pass a cap together or lose
/// each other's settings.
static PLUGIN_DATA_LOCK: Mutex<()> = Mutex::new(());
/// The closed flag of each plug-in folder's storage, shared by every storage made for the folder
/// until the plug-in is removed, which sets it and drops it.
static PLUGIN_DATA_OPEN: Mutex<BTreeMap<PathBuf, Arc<AtomicBool>>> = Mutex::new(BTreeMap::new());
/// The file name (without extension) a stored item called `name` gets: its name made safe for any
/// file system, then a hash of the name, so that names that differ only in characters a file name
@@ -262,8 +289,9 @@ impl Session {
/// Install the plug-ins kept in the plug-in folder: none in Safe Mode, and none the user
/// disabled (matched by file name, which is made from the id). The launch marker names each
/// file while it loads. A module that doesn't load, and one that lists permissions the grants
/// there don't grant that very module, is skipped with a log record, and stays in the folder
/// with its grant. A grant for an id whose stored module lists no permissions is forgotten.
/// there don't grant that very module ([`grant_covers`]), is skipped with a log record, and
/// stays in the folder with its grant. A grant for an id whose stored module lists no
/// permissions is forgotten.
pub fn load_stored_plugins(&mut self) {
let Some(dir) = self.plugin_dir.clone() else { return };
if safe_mode::active() {
@@ -296,17 +324,21 @@ impl Session {
// module stored here load.
Ok(plugin) if plugin.manifest().asks_permissions() => {
let m = plugin.manifest();
match grants.get(&m.id).filter(|g| g.covers(&m.id, plugin.hash(), &m.permissions())) {
match grants.get(&m.id).filter(|g| grant_covers(g, &plugin)) {
Some(g) => {
asks_nothing.remove(&m.id);
vectorcraft_plugins::registry::grant(g.clone());
vectorcraft_plugins::registry::set_storage(&m.id, plugin_storage(Some(&dir), &m.id));
vectorcraft_plugins::registry::install(plugin.with_source(path.display().to_string()));
}
None => log::warn!("skipping the stored plug-in {}: the user hasn't approved this module's permissions", path.display()),
None => {
log::warn!("skipping the stored plug-in {}: the user hasn't granted this module the permissions it lists", path.display())
}
}
}
Ok(plugin) => {
asks_nothing.insert(plugin.id().to_string());
vectorcraft_plugins::registry::set_storage(plugin.id(), plugin_storage(Some(&dir), plugin.id()));
vectorcraft_plugins::registry::install(plugin.with_source(path.display().to_string()));
}
Err(e) => log::warn!("skipping the stored plug-in {}: {e}", path.display()),
@@ -357,7 +389,7 @@ impl Session {
/// Whether `plugin` installs without asking the user: it lists no permissions, or this very
/// module (its id and SHA-256) was granted every one it lists, in the grants of the attached
/// plug-in store when there is one, else in this process's
/// plug-in store when there is one ([`grant_covers`]), else in this process's
/// ([`vectorcraft_plugins::registry::approved`]).
pub(crate) fn plugin_approved(&self, plugin: &vectorcraft_plugins::Plugin) -> bool {
self.plugin_approver()(plugin)
@@ -370,7 +402,7 @@ impl Session {
move |plugin| {
let m = plugin.manifest();
match &grants {
Some(grants) if m.asks_permissions() => grants.get(&m.id).is_some_and(|g| g.covers(&m.id, plugin.hash(), &m.permissions())),
Some(grants) if m.asks_permissions() => grants.get(&m.id).is_some_and(|g| grant_covers(g, plugin)),
_ => vectorcraft_plugins::registry::approved(plugin),
}
}
@@ -382,6 +414,29 @@ impl Session {
self.plugin_dir.as_deref().is_some_and(|dir| put(dir, &format!("{}.{PLUGIN_EXT}", file_stem(id)), bytes))
}
/// The module the store holds for the plug-in `id`: `None` without a folder or such a file, and
/// an error, with a log record, when the file can't be read or is larger than the module limit.
pub(crate) fn stored_plugin_bytes(&self, id: &str) -> Result<Option<Vec<u8>>, String> {
let Some(dir) = self.plugin_dir.as_deref() else { return Ok(None) };
let path = dir.join(format!("{}.{PLUGIN_EXT}", file_stem(id)));
if !path.exists() {
return Ok(None);
}
let max = vectorcraft_plugins::Limits::default().max_module_bytes as u64;
read_capped(&path, max).map(Some).inspect_err(|e| log::warn!("can't read the stored plug-in {}: {e}", path.display()))
}
/// Return the store's module for the plug-in `id` to `previous`, the bytes it held before a
/// write ([`Session::stored_plugin_bytes`]): with `None`, the file is removed.
pub(crate) fn put_back_plugin(&self, id: &str, previous: Option<&[u8]>) {
match previous {
Some(bytes) => {
self.store_plugin(id, bytes);
}
None => self.unstore_plugin(id),
}
}
/// The file the plug-in `id` is stored in, spelled as [`Session::load_stored_plugins`] names it
/// (`None` without a plug-in folder).
pub(crate) fn stored_plugin_file(&self, id: &str) -> Option<String> {
@@ -392,14 +447,15 @@ impl Session {
/// other grants as they are; with none left, remove the file. Entries this version can't use
/// are written back as they were, except one for `id`. A grants file that is too large, can't
/// be read or doesn't hold a list is left as it is, with a log record, and nothing is recorded;
/// so is one whose new content would be larger than `MAX_GRANTS_FILE`.
pub(crate) fn store_grant(&self, id: &str, grant: Option<vectorcraft_plugins::permissions::Grant>) {
let Some(dir) = &self.plugin_dir else { return };
/// so is one whose new content would be larger than `MAX_GRANTS_FILE`. → whether the store now
/// holds what was asked (`false` without a folder).
pub(crate) fn store_grant(&self, id: &str, grant: Option<vectorcraft_plugins::permissions::Grant>) -> bool {
let Some(dir) = &self.plugin_dir else { return false };
let mut file = match read_grants(dir) {
Ok(file) => file,
Err(e) => {
log::warn!("not updating the stored plug-in grants {}: {e}", dir.join(GRANTS_FILE).display());
return;
return false;
}
};
let others = file.other.len();
@@ -409,22 +465,23 @@ impl Session {
None => file.grants.remove(id).is_some(),
} || file.other.len() != others;
if !changed {
return;
return true;
}
let mut entries: Vec<serde_json::Value> = file.grants.into_values().filter_map(|g| serde_json::to_value(g).ok()).collect();
entries.extend(file.other);
if entries.is_empty() {
take(dir, GRANTS_FILE);
return;
return true;
}
// The file is read back only up to its cap: written compactly when pretty-printed is larger,
// and not at all when that is too.
let fits = |bytes: &Vec<u8>| bytes.len() as u64 <= MAX_GRANTS_FILE;
match serde_json::to_vec_pretty(&entries).ok().filter(fits).or_else(|| serde_json::to_vec(&entries).ok().filter(fits)) {
Some(bytes) => {
put(dir, GRANTS_FILE, &bytes);
Some(bytes) => put(dir, GRANTS_FILE, &bytes),
None => {
log::warn!("not updating the stored plug-in grants {}: {}", dir.join(GRANTS_FILE).display(), too_large(MAX_GRANTS_FILE));
false
}
None => log::warn!("not updating the stored plug-in grants {}: {}", dir.join(GRANTS_FILE).display(), too_large(MAX_GRANTS_FILE)),
}
}
@@ -434,6 +491,380 @@ impl Session {
take(dir, &format!("{}.{PLUGIN_EXT}", file_stem(id)));
}
}
/// Keep the settings and cache of plug-in `id` in the attached plug-in store from now on; without
/// one, nowhere (its storage calls then fail as unavailable).
pub(crate) fn attach_plugin_storage(&self, id: &str) {
vectorcraft_plugins::registry::set_storage(id, plugin_storage(self.plugin_dir.as_deref(), id));
}
/// Does the attached plug-in store have a folder of settings and cache for plug-in `id`? A
/// folder behind a `storage` folder that is a link is not counted: the plug-in's storage calls
/// can't reach it and removing the plug-in leaves it.
pub(crate) fn has_plugin_storage(&self, id: &str) -> bool {
self.plugin_dir
.as_deref()
.is_some_and(|dir| !is_link(&dir.join(PLUGIN_DATA_DIR)) && std::fs::symlink_metadata(plugin_data_dir(dir, id)).is_ok())
}
/// Delete the settings and cache of plug-in `id` from the store, and stop keeping any for it.
/// The folder is first renamed out of the way (`<folder>.removed`), and that fails, leaving it
/// as it was, when the rename does; what the deletion then leaves stays under that name, with a
/// log record, and is deleted at the plug-in's next removal.
pub(crate) fn remove_plugin_storage(&self, id: &str) -> Result<(), StorageError> {
vectorcraft_plugins::registry::set_storage(id, None);
let Some(dir) = &self.plugin_dir else { return Ok(()) };
let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let path = plugin_data_dir(dir, id);
// Every storage handed out for the folder, including those runs still hold, is closed once
// the folder is out of the way; until then they keep working.
let close = || {
if let Some(closed) = PLUGIN_DATA_OPEN.lock().unwrap_or_else(|e| e.into_inner()).remove(&path) {
closed.store(true, Ordering::Release);
}
};
// A `storage` folder that is a link leads out of the store: nothing there is deleted. A link
// in place of the plug-in's own folder is removed itself (`remove_dir_all` doesn't follow it).
let data = dir.join(PLUGIN_DATA_DIR);
if is_link(&data) {
log::warn!("{} is a link: the plug-in settings and cache it leads to are left", data.display());
close();
return Ok(());
}
let mut aside = path.clone().into_os_string();
aside.push(".removed");
let aside = PathBuf::from(aside);
let _ = std::fs::remove_dir_all(&aside);
match std::fs::rename(&path, &aside) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(StorageError::Io(format!("{}: {e}", path.display()))),
Ok(()) => {}
}
close();
if std::fs::symlink_metadata(&aside).is_ok()
&& let Err(e) = std::fs::remove_dir_all(&aside)
{
log::warn!("can't delete {}: {e}", aside.display());
}
Ok(())
}
}
/// `name` (a plug-in id or a cache entry name, ASCII) written for a file name that no other name
/// gets on a file system that ignores case: `_` is written `__`, each capital letter `_` and the
/// letter in lower case, and `.` `_0`.
fn case_safe(name: &str) -> String {
let mut file = String::with_capacity(name.len() * 2);
for c in name.chars() {
match c {
'_' => file.push_str("__"),
'.' => file.push_str("_0"),
c if c.is_ascii_uppercase() => {
file.push('_');
file.push(c.to_ascii_lowercase());
}
c => file.push(c),
}
}
file
}
/// The folder of plug-in `id`'s settings and cache in the plug-in store `dir`. The `id-` in front
/// keeps an id such as `con` from naming a device on Windows.
pub(crate) fn plugin_data_dir(dir: &Path, id: &str) -> PathBuf {
dir.join(PLUGIN_DATA_DIR).join(format!("id-{}", case_safe(id)))
}
/// Where plug-in `id` keeps its settings and cache in the plug-in store `dir`.
fn plugin_storage(dir: Option<&Path>, id: &str) -> Option<Arc<dyn Storage>> {
let dir = dir?;
let data = plugin_data_dir(dir, id);
let closed = PLUGIN_DATA_OPEN.lock().unwrap_or_else(|e| e.into_inner()).entry(data.clone()).or_default().clone();
Some(Arc::new(PluginStorage { root: dir.to_path_buf(), dir: data, closed }))
}
/// The settings and cache of one plug-in, in its folder of the plug-in store (see the module docs).
/// Keys and entry names are [`storage::valid_name`]s; an entry's file name is its name as
/// [`case_safe`] writes it, so that names differing only in case get different files. A link
/// anywhere in the folder's path below the store is refused, and so is a cache file that is not a
/// plain file.
pub(crate) struct PluginStorage {
/// The plug-in store, and the plug-in's folder in it.
root: PathBuf,
dir: PathBuf,
/// Set, under [`PLUGIN_DATA_LOCK`], once the plug-in is removed; shared by every storage of the
/// folder ([`PLUGIN_DATA_OPEN`]).
closed: Arc<AtomicBool>,
}
/// The sizes of `files` added up, at most `u64::MAX`: the sizes come from the file system.
fn sum_sizes(files: &[(PathBuf, u64)]) -> u64 {
files.iter().fold(0, |sum, (_, n)| sum.saturating_add(*n))
}
/// Is `name` the file name of a cache entry? Entry files end in `.bin` and never start with a `.`
/// ([`case_safe`] writes no `.`), while the temporary files of `write_atomic` do.
fn is_entry_file(name: &std::ffi::OsStr) -> bool {
name.to_str().is_some_and(|n| !n.starts_with('.') && n.ends_with(&format!(".{CACHE_EXT}")))
}
/// What a storage call is told when the file system fails.
fn io(e: &std::io::Error) -> StorageError {
StorageError::Io(e.to_string())
}
/// Does grant `g` cover `plugin`: was it given to this module (the same id and SHA-256) for every
/// permission the module lists? A module with other bytes under the id needs the user's approval
/// again, and so doesn't get the settings and cache kept for the id without it. A grant whose hash
/// is missing or not a valid SHA-256 covers no module.
fn grant_covers(g: &vectorcraft_plugins::permissions::Grant, plugin: &vectorcraft_plugins::Plugin) -> bool {
let m = plugin.manifest();
g.covers(&m.id, plugin.hash(), &m.permissions())
}
/// Is `path` a link (a symbolic link or, on Windows, a junction)?
fn is_link(path: &Path) -> bool {
std::fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink() || reparse_point(&m))
}
#[cfg(windows)]
fn reparse_point(m: &std::fs::Metadata) -> bool {
use std::os::windows::fs::MetadataExt;
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
m.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
}
#[cfg(not(windows))]
fn reparse_point(_: &std::fs::Metadata) -> bool {
false
}
impl PluginStorage {
fn settings_path(&self) -> PathBuf {
self.dir.join(SETTINGS_FILE)
}
fn cache_dir(&self) -> PathBuf {
self.dir.join(CACHE_DIR)
}
/// The file of the cache entry `name`.
fn entry_path(&self, name: &str) -> PathBuf {
self.cache_dir().join(format!("{}.{CACHE_EXT}", case_safe(name)))
}
/// Checks that no folder between the store and `path`, nor `path` itself, is a link.
fn no_links(&self, path: &Path) -> Result<(), StorageError> {
let mut at = path;
while at != self.root {
if is_link(at) {
return Err(StorageError::Unavailable);
}
match at.parent() {
Some(up) => at = up,
None => break,
}
}
Ok(())
}
/// The settings, by key. A file that is too large or doesn't parse is moved aside
/// ([`BAD_SETTINGS_FILE`]) with a log record, and the settings are empty. `meter` is charged the
/// file's size before it is read.
fn read_settings(&self, meter: &mut Meter) -> Result<BTreeMap<String, Vec<u8>>, StorageError> {
let path = self.settings_path();
self.no_links(&path)?;
let size = match std::fs::metadata(&path) {
Ok(m) => m.len(),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()),
Err(e) => return Err(io(&e)),
};
let readable = size <= storage::MAX_SETTINGS_BYTES as u64;
if readable {
meter.take(size)?;
}
let parsed = readable
.then(|| read_capped(&path, storage::MAX_SETTINGS_BYTES as u64).ok())
.flatten()
.and_then(|bytes| serde_json::from_slice::<BTreeMap<String, String>>(&bytes).ok())
.and_then(|stored| {
let decode =
|(k, v): (String, String)| if storage::valid_name(&k) { vectorcraft_format::base64_decode(&v).map(|v| (k, v)) } else { None };
let settings: Option<BTreeMap<String, Vec<u8>>> = stored.into_iter().map(decode).collect();
settings.filter(|s| s.len() <= storage::MAX_SETTINGS && s.values().all(|v| v.len() <= storage::MAX_SETTING_BYTES))
});
match parsed {
Some(settings) => Ok(settings),
None => {
let aside = self.dir.join(BAD_SETTINGS_FILE);
log::warn!("the plug-in settings {} don't read: moved to {} and started empty", path.display(), aside.display());
std::fs::rename(&path, &aside).map_err(|e| io(&e))?;
Ok(BTreeMap::new())
}
}
}
/// Writes `settings` as the settings file, or removes it when there are none. `meter` is charged
/// the new file's size before it is written.
fn write_settings(&self, settings: &BTreeMap<String, Vec<u8>>, meter: &mut Meter) -> Result<(), StorageError> {
let path = self.settings_path();
if settings.is_empty() {
return match std::fs::remove_file(&path) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(io(&e)),
_ => Ok(()),
};
}
let stored: BTreeMap<&str, String> = settings.iter().map(|(k, v)| (k.as_str(), vectorcraft_format::base64_encode(v))).collect();
let bytes = serde_json::to_vec(&stored).map_err(|e| StorageError::Io(e.to_string()))?;
if bytes.len() > storage::MAX_SETTINGS_BYTES {
return Err(StorageError::Full);
}
meter.take(bytes.len() as u64)?;
std::fs::create_dir_all(&self.dir).map_err(|e| io(&e))?;
self.no_links(&path)?;
write_atomic(&path, &bytes).map_err(|e| io(&e))
}
/// Fails with [`StorageError::Unavailable`] once the storage is closed.
fn open(&self) -> Result<(), StorageError> {
if self.closed.load(Ordering::Acquire) { Err(StorageError::Unavailable) } else { Ok(()) }
}
/// The entries of the cache folder (plain files named as [`Self::entry_path`] names them, which
/// leaves out the temporary files of writes) and their sizes, and whether the folder holds more
/// than [`MAX_CACHE_SCAN`] items, in which case only the entries among the first that many are
/// listed.
fn cache_files(&self) -> Result<(Vec<(PathBuf, u64)>, bool), StorageError> {
let dir = self.cache_dir();
self.no_links(&dir)?;
let entries = match std::fs::read_dir(&dir) {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok((vec![], false)),
Err(e) => return Err(io(&e)),
};
let mut files = Vec::new();
for (seen, e) in entries.enumerate() {
if seen == MAX_CACHE_SCAN {
return Ok((files, true));
}
let e = e.map_err(|e| io(&e))?;
if is_entry_file(&e.file_name())
&& let Ok(m) = std::fs::symlink_metadata(e.path())
&& m.is_file()
&& !reparse_point(&m)
{
files.push((e.path(), m.len()));
}
}
Ok((files, false))
}
}
impl Storage for PluginStorage {
fn setting(&self, key: &str, meter: &mut Meter) -> Result<Option<Vec<u8>>, StorageError> {
let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner());
self.open()?;
Ok(self.read_settings(meter)?.remove(key))
}
fn set_setting(&self, key: &str, value: &[u8], meter: &mut Meter) -> Result<(), StorageError> {
let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner());
self.open()?;
let mut settings = self.read_settings(meter)?;
if value.len() > storage::MAX_SETTING_BYTES || (!settings.contains_key(key) && settings.len() >= storage::MAX_SETTINGS) {
return Err(StorageError::Full);
}
settings.insert(key.to_string(), value.to_vec());
self.write_settings(&settings, meter)
}
fn delete_setting(&self, key: &str, meter: &mut Meter) -> Result<bool, StorageError> {
let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner());
self.open()?;
let mut settings = self.read_settings(meter)?;
if settings.remove(key).is_none() {
return Ok(false);
}
self.write_settings(&settings, meter).map(|()| true)
}
fn cache_entry(&self, name: &str, meter: &mut Meter) -> Result<Option<Vec<u8>>, StorageError> {
let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner());
self.open()?;
let path = self.entry_path(name);
self.no_links(&path)?;
match std::fs::symlink_metadata(&path) {
Ok(m) if m.is_file() && m.len() <= storage::MAX_CACHE_ENTRY as u64 => meter.take(m.len())?,
Ok(_) => return Err(StorageError::Unavailable),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(io(&e)),
}
read_capped(&path, storage::MAX_CACHE_ENTRY as u64).map(Some).map_err(StorageError::Io)
}
fn set_cache_entry(&self, name: &str, value: &[u8], meter: &mut Meter) -> Result<(), StorageError> {
let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner());
self.open()?;
let path = self.entry_path(name);
let (files, overfull) = self.cache_files()?;
if overfull {
return Err(StorageError::Full);
}
let old = files.iter().find(|(p, _)| *p == path).map(|(_, n)| *n);
let total = sum_sizes(&files).saturating_sub(old.unwrap_or(0)).saturating_add(value.len() as u64);
let too_many = old.is_none() && files.len() >= storage::MAX_CACHE_ENTRIES;
if value.len() > storage::MAX_CACHE_ENTRY || total > storage::MAX_CACHE_BYTES || too_many {
return Err(StorageError::Full);
}
meter.take(value.len() as u64)?;
std::fs::create_dir_all(self.cache_dir()).map_err(|e| io(&e))?;
self.no_links(&path)?;
write_atomic(&path, value).map_err(|e| io(&e))
}
fn remove_cache_entry(&self, name: &str) -> Result<bool, StorageError> {
let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner());
self.open()?;
let path = self.entry_path(name);
self.no_links(&path)?;
match std::fs::remove_file(&path) {
Ok(()) => Ok(true),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(e) => Err(io(&e)),
}
}
fn clear_cache(&self) -> Result<u64, StorageError> {
let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner());
self.open()?;
let dir = self.cache_dir();
self.no_links(&dir)?;
let entries = match std::fs::read_dir(&dir) {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(0),
Err(e) => return Err(io(&e)),
};
// Every plain file, one at a time as the folder is read, past the ones a write looks at too.
let mut freed = 0_u64;
for e in entries {
let path = e.map_err(|e| io(&e))?.path();
if let Ok(m) = std::fs::symlink_metadata(&path)
&& m.is_file()
&& !reparse_point(&m)
{
std::fs::remove_file(&path).map_err(|e| io(&e))?;
freed = freed.saturating_add(m.len());
}
}
Ok(freed)
}
fn cache_bytes(&self) -> u64 {
if self.open().is_err() {
return 0;
}
self.cache_files().map(|(files, _)| sum_sizes(&files)).unwrap_or(0)
}
}
/// What the grants file of a plug-in store holds.
+36
View File
@@ -3012,6 +3012,42 @@ fn a_trace_run_hands_its_trace_to_the_command() {
assert!((bb.center().x - 160.0).abs() < 1.5, "traced where the image is now: {bb:?}");
}
#[test]
fn image_trace_ignore_background_keeps_the_color_inside_the_art() {
// A black field, a light panel on it and a black ring on the panel.
let art = vectorcraft_trace::Raster::from_fn(200, 150, |x, y| {
let r = (f64::from(x) + 0.5 - 100.0).hypot(f64::from(y) + 0.5 - 75.0);
if !((20..180).contains(&x) && (20..130).contains(&y)) || (37.0..=43.0).contains(&r) { [0, 0, 0, 255] } else { [240, 240, 220, 255] }
});
let traced = |background: bool| {
let mut s = session();
add_image(&mut s, &art, Affine::IDENTITY);
let params = json!({"params": {"mode": "color", "palette": "limited", "colors": 2, "ignoreWhite": true, "ignoreColor": "#000000", "ignoreBackground": background}});
let r = s.execute("imageTrace.makeAndExpand", &params).unwrap();
let g = node(&s, NodeId(r["id"].as_u64().unwrap()));
let black: Vec<_> = g
.children()
.unwrap()
.iter()
.filter(|n| n.appearance.fill_paint() == Paint::solid(Color::rgb8(0, 0, 0)))
.map(|n| outline(n).0.bounds().unwrap())
.collect();
(r["colors"].clone(), black)
};
// The whole color: the ring goes with the field.
let (colors, black) = traced(false);
assert_eq!(colors, 1);
assert!(black.is_empty(), "{black:?}");
// The background only: the ring stays, and nothing black reaches the image's edge.
let (colors, black) = traced(true);
assert_eq!(colors, 2);
assert_eq!(black.len(), 1, "{black:?}");
let bb = black[0];
assert!((bb.x0 - 57.0).abs() < 1.5 && (bb.x1 - 143.0).abs() < 1.5 && (bb.y0 - 32.0).abs() < 1.5 && (bb.y1 - 118.0).abs() < 1.5, "{bb:?}");
// The params doc tells agents about the option.
assert!(find_command("imageTrace.make").unwrap().params.contains("ignoreBackground: false"));
}
/// A Document Library trace made away from its command no longer applies once the library's
/// colours change: the settings' JSON leaves them out (#4605 review).
#[test]
+1 -1
View File
@@ -374,7 +374,7 @@ fn rgb_art_separates_with_gcr() {
RenderOptions { proof: Some(ProofSetup { separations: Some(vec!["Black".into()]), ..Default::default() }), ..Default::default() },
)
.pixel(50, 50);
assert!(k[0] < 20, "RGB black is mostly K: {k:?}");
assert!((20..=30).contains(&k[0]), "RGB black carries 90% black: {k:?}");
let c = render(
&doc,
RenderOptions { proof: Some(ProofSetup { separations: Some(vec!["Cyan".into()]), ..Default::default() }), ..Default::default() },
+62 -3
View File
@@ -28,16 +28,28 @@ fn create_text(s: &mut Session, t: &str) -> NodeId {
NodeId(s.execute("text.create", &json!({"x": 10, "y": 50, "text": t, "size": 20})).unwrap()["id"].as_u64().unwrap())
}
/// Click with the Type tool at (x, y) and type `keys` one at a time; returns the new text object.
/// Click with the Type tool at (x, y) and type `keys` one at a time on the keyboard; returns the
/// new text object.
fn type_at(s: &mut Session, x: f64, y: f64, keys: &[&str]) -> NodeId {
enter_at(s, x, y, keys, true)
}
/// Click with the Type tool at (x, y) and enter `texts` one at a time, typed on the keyboard
/// ([`Session::tool_typed_text`]) or inserted as the Glyphs panel and pastes do
/// ([`Session::tool_text`]); returns the new text object.
fn enter_at(s: &mut Session, x: f64, y: f64, texts: &[&str], keyboard: bool) -> NodeId {
let v = ViewInfo::default();
s.select_tool("type", v).unwrap();
for k in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(k, x, y), v).unwrap();
}
let id = s.doc().unwrap().selection.objects[0];
for k in keys {
s.tool_text(k, v).unwrap();
for t in texts {
if keyboard {
s.tool_typed_text(t, v).unwrap();
} else {
s.tool_text(t, v).unwrap();
}
}
s.tool_key(ToolKey::Escape, Default::default(), v).unwrap();
id
@@ -177,6 +189,53 @@ fn typographers_quotes_setting_changes_typed_quotes() {
assert_eq!(text(&s, id).plain_text(), "\"b'");
}
/// A lone typed `”` is set like a straight quote where a quote opens: the macOS Serbian Cyrillic
/// layout has no straight quote and types `”` on Shift+2 (#5247), so Serbian text still gets
/// „…”. Where a quote closes it stays `”`. `“ „` and the single quotes stay as typed, so `’90s`
/// keeps its apostrophe. Pasted text and the Glyphs panel keep their quotes, and with the option
/// off `”` stays as typed.
#[test]
fn a_typed_closing_double_quote_opens_where_a_quote_opens() {
let mut s = session();
s.execute("document.setup", &json!({"language": "Serbian: Cyrillic"})).unwrap();
let id = type_at(&mut s, 50.0, 50.0, &["”", "П", "р", "о", "б", "а", "”"]);
assert_eq!(text(&s, id).plain_text(), "„Проба”");
// The other curly quotes stay as typed.
let id = type_at(&mut s, 50.0, 100.0, &["„", "a", " ", "’", "b", "’", "“"]);
assert_eq!(text(&s, id).plain_text(), "„a ’b’“");
// In English a `”` that opens is `“`; the apostrophes stay.
s.execute("document.setup", &json!({"language": "English: USA"})).unwrap();
let id = type_at(&mut s, 50.0, 150.0, &["”", "i", "t", "’", "s", "”"]);
assert_eq!(text(&s, id).plain_text(), "“it’s”");
// An apostrophe after a space stays an apostrophe, and a typed `‘` stays `‘`.
let id = type_at(&mut s, 50.0, 200.0, &["t", "h", "e", " ", "’", "9", "0", "s", " ", "‘", "a"]);
assert_eq!(text(&s, id).plain_text(), "the ’90s ‘a");
// Where a quote closes, `”` stays `”` even if the document's closing quote is another.
s.execute("document.setup", &json!({"language": "German"})).unwrap();
let id = type_at(&mut s, 50.0, 250.0, &["”", "a", "”"]);
assert_eq!(text(&s, id).plain_text(), "„a”");
// More than one character is pasted text, not a typed quote.
let id = type_at(&mut s, 50.0, 300.0, &["”a”"]);
assert_eq!(text(&s, id).plain_text(), "”a”");
// An IME commit of a lone `”` that will open is not verbatim (it is rewritten); from the Glyphs
// panel it would be inserted as it is.
let v = ViewInfo::default();
s.select_tool("type", v).unwrap();
for k in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(k, 50.0, 312.0), v).unwrap();
}
assert!(!s.tool_typed_text_is_verbatim("”", v));
assert!(s.tool_text_is_verbatim("”", v));
s.tool_key(ToolKey::Escape, Default::default(), v).unwrap();
// A `”` inserted from the Glyphs panel or pasted alone stays as inserted.
let id = enter_at(&mut s, 50.0, 325.0, &["”", "a"], false);
assert_eq!(text(&s, id).plain_text(), "”a");
// With the option off a typed `”` stays as typed.
s.execute("document.setup", &json!({"typographersQuotes": false})).unwrap();
let id = type_at(&mut s, 50.0, 350.0, &["”", "b", "”"]);
assert_eq!(text(&s, id).plain_text(), "”b”");
}
#[test]
fn smart_punctuation_uses_the_document_quotes() {
let mut s = session();
+81
View File
@@ -2673,6 +2673,87 @@ fn pen_with_cmd_borrows_direct_selection_and_goes_on_drawing() {
assert_eq!(s.tool_id(), "rectangle");
}
/// #6123: anchors that Cmd (Ctrl) held with the Pen selects with the Direct Selection it lends are
/// what Copy and Cut take, as with Direct Selection chosen; the last anchor the Pen leaves selected
/// while drawing is not, Copy taking the whole path (#2122), nor after a Cmd gesture that picks
/// nothing.
#[test]
fn pen_cmd_selected_anchors_are_what_copy_and_cut_take() {
let (none, cmd) = (Mods::default(), Mods { cmd: true, ..Mods::default() });
let v = view();
let mut s = session();
s.select_tool("pen", v).unwrap();
for x in [100.0, 200.0, 300.0, 400.0] {
gesture(&mut s, &[(x, 300.0)], none);
}
let id = s.doc().unwrap().selection.objects[0];
let pasted = |s: &mut Session| {
let ids = s.execute("edit.paste", &json!({})).unwrap()["ids"].clone();
let ids: Vec<NodeId> = ids.as_array().unwrap().iter().map(|i| NodeId(i.as_u64().unwrap())).collect();
ids.iter().map(|i| path(s, *i).subpaths[0].anchors.len()).collect::<Vec<_>>()
};
// A Cmd marquee around the middle two anchors: Copy takes them and the segment between.
gesture(&mut s, &[(150.0, 250.0), (350.0, 350.0)], cmd);
assert_eq!(s.tool_id(), "pen");
assert_eq!(s.doc().unwrap().selection.anchors[&id], [(0, 1), (0, 2)].into());
s.execute("edit.copy", &json!({})).unwrap();
assert_eq!(pasted(&mut s), [2]);
// Cut takes the segment between them out of the path, as with Direct Selection chosen.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.cut", &json!({})).unwrap();
let left = path(&s, id).subpaths.iter().map(|sp| sp.anchors.len()).collect::<Vec<_>>();
assert_eq!((s.clipboard.nodes.len(), left), (1, vec![2, 2]));
// Drawing on: the last anchor the Pen leaves selected doesn't make Copy take that anchor.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
gesture(&mut s, &[(500.0, 300.0)], none);
assert_eq!(s.doc().unwrap().selection.anchors[&id], [(0, 4)].into());
s.execute("edit.copy", &json!({})).unwrap();
assert_eq!(pasted(&mut s), [5]);
// A Cmd+Shift marquee over nothing picks nothing: the Pen's last anchor stays its own.
s.execute("edit.undo", &json!({})).unwrap();
let shift_cmd = Mods { shift: true, ..cmd };
gesture(&mut s, &[(600.0, 500.0), (700.0, 600.0)], shift_cmd);
assert_eq!(s.doc().unwrap().selection.anchors[&id], [(0, 4)].into());
s.execute("edit.copy", &json!({})).unwrap();
assert_eq!(pasted(&mut s), [5]);
// The tool started afresh in the same document (another view of it) keeps a pick picked.
s.execute("edit.undo", &json!({})).unwrap();
gesture(&mut s, &[(150.0, 250.0), (350.0, 350.0)], cmd);
s.reset_tool();
s.execute("edit.copy", &json!({})).unwrap();
assert_eq!(pasted(&mut s), [2]);
}
/// #6123: with the other tools, Cmd (Ctrl) lends the selection tool chosen last. Direct Selection
/// lent picks anchors, which Copy takes, as with the Curvature tool, which Cmd lends Direct
/// Selection until a selection tool is chosen; the Selection tool lent selects the path whole.
#[test]
fn cmd_selected_anchors_with_other_tools_are_what_copy_takes() {
let cmd = Mods { cmd: true, ..Mods::default() };
let v = view();
let copied = |tool: &str, last_selection: Option<&str>| {
let mut s = session();
let anchors = json!([{"x": 100, "y": 300}, {"x": 200, "y": 300}, {"x": 300, "y": 300}, {"x": 400, "y": 300}]);
s.execute("path.create", &json!({"anchors": anchors})).unwrap();
s.execute("select.none", &json!({})).unwrap();
if let Some(t) = last_selection {
s.select_tool(t, v).unwrap();
}
s.select_tool(tool, v).unwrap();
gesture(&mut s, &[(150.0, 250.0), (350.0, 350.0)], cmd);
assert_eq!(s.tool_id(), tool);
s.execute("edit.copy", &json!({})).unwrap();
let ids = s.execute("edit.paste", &json!({})).unwrap()["ids"].clone();
let id = NodeId(ids[0].as_u64().unwrap());
path(&s, id).subpaths[0].anchors.len()
};
assert_eq!(copied("rectangle", Some("directSelection")), 2);
assert_eq!(copied("ellipse", Some("directSelection")), 2);
assert_eq!(copied("curvature", None), 2);
assert_eq!(copied("rectangle", None), 4, "the Selection tool lent selects the path whole");
}
/// #4980: Cmd/Ctrl held with the Pen drags as Direct Selection: an anchor of the path being drawn
/// moves on its own and the Pen goes on drawing that path from it; another path's straight segment
/// moves both its ends and a curved one bends; each drag is one undo step and the pointer is the
+309
View File
@@ -4,6 +4,7 @@
//! Fill or Stroke changes it.
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_geom::Rect;
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
@@ -107,6 +108,314 @@ fn static_symbols_and_bad_targets_are_refused() {
assert_eq!(overrides(&s, inst), 0);
}
fn undo_len(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
/// The frontmost object of the active layer.
fn top(s: &Session) -> NodeId {
let d = &s.doc().unwrap().doc;
d.node(d.default_layer().unwrap()).unwrap().children().unwrap().last().unwrap().id
}
/// Every pixel of the page.
fn page(s: &Session) -> Vec<[u8; 4]> {
let r = vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 200.0, 100.0), 1.0, true);
(0..r.height).flat_map(|y| (0..r.width).map(move |x| (x, y))).map(|(x, y)| r.pixel(x, y)).collect()
}
/// A dynamic symbol "Art" of the frontmost object, made in place: its instance.
fn art_of_top(s: &mut Session) -> NodeId {
let t = top(s);
id(&s.execute("symbol.new", &json!({"name": "Art", "ids": [t.0], "type": "dynamic"})).unwrap())
}
/// Put a red fill and stroke on the root of instance `inst`'s art directly, past the command.
fn paint_root_red(s: &mut Session, inst: NodeId) {
let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0));
s.edit("paint", |d, _| {
let o = vectorcraft_doc::symbol_overrides::SymbolOverride { path: vec![], fill: Some(red.clone()), stroke: Some(red), ..Default::default() };
d.node_mut(inst).ok_or(EngineError::NoNode(inst))?.extras_mut().symbol_overrides.push(o);
Ok(())
})
.unwrap();
}
/// A 30×30 red image placed with its frame from (50, 20) to (80, 50).
fn place_image(s: &mut Session) {
let png = crate::tests_links::png(30, 30, [255, 0, 0]);
let data = vectorcraft_format::base64_encode(&png);
s.execute("file.place", &json!({"name": "red.png", "dataBase64": data, "at": [65, 35], "link": false})).unwrap();
}
fn select_top(s: &mut Session, n: usize) {
let d = &s.doc().unwrap().doc;
let kids = d.node(d.default_layer().unwrap()).unwrap().children().unwrap();
let ids: Vec<u64> = kids[kids.len() - n..].iter().map(|c| c.id.0).collect();
s.execute("select.set", &json!({ "ids": ids })).unwrap();
}
#[test]
fn objects_that_draw_no_fill_or_stroke_of_their_own_refuse_one() {
let folder = crate::tests_links::Folder::new("dynamic-symbol-kinds");
let source = folder.file("placed.vectorcraft");
let mut doc = vectorcraft_doc::Document::new(40.0, 40.0);
let layer = doc.layers[0].id;
let look = vectorcraft_doc::Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0);
let rect = vectorcraft_doc::Node::path(doc.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 40.0)), look);
doc.insert(Some(layer), 0, rect).unwrap();
crate::tests_links::write(&source, &vectorcraft_format::save_file(&doc));
type Make = Box<dyn Fn(&mut Session)>;
let cases: Vec<(&str, &str, Make)> = vec![
(
"guide",
"a guide",
Box::new(|s| {
square(s, 10.0);
select_top(s, 1);
s.execute("view.guides.make", &json!({})).unwrap();
}),
),
(
"blend",
"a blend",
Box::new(|s| {
let (a, b) = (square(s, 10.0), square(s, 60.0));
s.execute("object.blend.make", &json!({"ids": [a.0, b.0], "steps": 1})).unwrap();
}),
),
(
"envelope",
"an envelope",
Box::new(|s| {
square(s, 10.0);
select_top(s, 1);
s.execute("object.envelope.makeWithWarp", &json!({"style": "arch", "bend": 50})).unwrap();
}),
),
("radial Repeat", "a Repeat", Box::new(|s| repeat(s, "object.repeat.radial"))),
("grid Repeat", "a Repeat", Box::new(|s| repeat(s, "object.repeat.grid"))),
("mirror Repeat", "a Repeat", Box::new(|s| repeat(s, "object.repeat.mirror"))),
(
"Intertwine",
"an Intertwine group",
Box::new(|s| {
square(s, 10.0);
square(s, 30.0);
select_top(s, 2);
s.execute("object.intertwine.make", &json!({})).unwrap();
}),
),
(
"gradient mesh",
"a gradient mesh",
Box::new(|s| {
let r = square(s, 10.0);
s.execute("object.mesh.create", &json!({"ids": [r.0], "rows": 1, "cols": 1})).unwrap();
}),
),
("image", "an image", Box::new(place_image)),
(
"placed document",
"a placed document",
Box::new(move |s| {
let r = s.execute("file.place", &json!({"path": source, "at": [40, 40]})).unwrap();
assert_eq!(r["linked"], true, "{r}");
}),
),
];
// Every case runs, and the failure lists each object that fails.
let mut failures = Vec::new();
for (label, kind, make) in cases {
let mut s = session();
make(&mut s);
let inst = art_of_top(&mut s);
if let Err(why) = refuses_own_paint(&mut s, inst, kind) {
failures.push(format!("{label}: {why}"));
}
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
/// Check that the root of instance `inst`'s art, an object of `kind` that draws no fill or stroke
/// of its own, refuses both, takes null, opacity and blend, and draws no stored fill or stroke.
fn refuses_own_paint(s: &mut Session, inst: NodeId, kind: &str) -> std::result::Result<(), String> {
let set = |s: &mut Session, p: &Value| s.execute("symbol.instance.setAppearance", p);
let undo = undo_len(s);
for key in ["fill", "stroke"] {
for paint in [json!("#ff0000"), json!({"none": true})] {
// With a valid opacity too: nothing of the call is applied.
let p = json!({"id": inst.0, "path": [], key: paint, "opacity": 50});
match set(s, &p) {
Ok(_) => return Err(format!("{p} was taken")),
Err(e) if !e.to_string().contains(&format!("{key} cannot be set on {kind}")) => return Err(format!("{p}: {e}")),
Err(_) => {}
}
}
}
if (overrides(s, inst), undo_len(s)) != (0, undo) {
return Err("a refused call changed the instance or added an undo step".into());
}
// Null (taking a change back), opacity and blend are taken.
let clear = json!({"id": inst.0, "path": [], "fill": null, "stroke": null});
for p in [clear, json!({"id": inst.0, "path": [], "opacity": 50, "blend": "multiply"})] {
set(s, &p).map_err(|e| format!("{p}: {e}"))?;
}
if overrides(s, inst) != 1 {
return Err("opacity and blend were not stored".into());
}
// The renderer draws no fill or stroke of the object's own: a stored one changes nothing.
let before = page(s);
paint_root_red(s, inst);
if page(s) != before {
return Err("a stored fill and stroke changed the canvas".into());
}
Ok(())
}
fn repeat(s: &mut Session, cmd: &str) {
square(s, 10.0);
select_top(s, 1);
s.execute(cmd, &json!({})).unwrap();
}
#[test]
fn a_symbol_instance_in_the_art_takes_a_fill_that_stains_it_and_no_stroke() {
let mut s = session();
pair(&mut s, "static");
let inst = art_of_top(&mut s);
let undo = undo_len(&s);
let e = s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [], "stroke": "#ff0000"})).unwrap_err().to_string();
assert!(e.contains("stroke cannot be set on a symbol instance"), "{e}");
assert_eq!((overrides(&s, inst), undo_len(&s)), (0, undo));
// A stroke stored past the command draws nothing.
let before = page(&s);
let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0));
s.edit("paint", |d, _| {
let o = vectorcraft_doc::symbol_overrides::SymbolOverride { path: vec![], stroke: Some(red), ..Default::default() };
d.node_mut(inst).ok_or(EngineError::NoNode(inst))?.extras_mut().symbol_overrides.push(o);
Ok(())
})
.unwrap();
assert!(page(&s) == before, "a stroke on a symbol instance draws nothing");
// The fill stains the nested instance's art.
s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [], "fill": "#ff0000"})).unwrap();
assert_eq!(pixel(&s, 30, 30), RED, "the fill stains the art");
}
#[test]
fn a_symbol_instance_in_the_art_refuses_a_gradient_fill() {
let mut s = session();
pair(&mut s, "static");
let inst = art_of_top(&mut s);
let undo = undo_len(&s);
let gradient = json!({"stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#00ff00"}]});
let p = json!({"id": inst.0, "path": [], "fill": {"gradient": gradient}, "opacity": 50});
let e = s.execute("symbol.instance.setAppearance", &p).unwrap_err().to_string();
assert!(e.contains("fill on a symbol instance must be a solid color or none"), "{e}");
assert_eq!((overrides(&s, inst), undo_len(&s)), (0, undo), "nothing changes and no undo step");
// A gradient fill stored past the command draws nothing.
let before = page(&s);
let paint = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default())));
s.edit("paint", |d, _| {
let o = vectorcraft_doc::symbol_overrides::SymbolOverride { path: vec![], fill: Some(paint), ..Default::default() };
d.node_mut(inst).ok_or(EngineError::NoNode(inst))?.extras_mut().symbol_overrides.push(o);
Ok(())
})
.unwrap();
assert!(page(&s) == before, "a gradient fill on a symbol instance draws nothing");
// A solid fill and none are taken.
s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [], "fill": {"none": true}})).unwrap();
s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [], "fill": "#ff0000"})).unwrap();
assert_eq!(pixel(&s, 30, 30), RED);
}
#[test]
fn a_symbol_instance_in_the_art_refuses_a_pattern_fill() {
let mut s = session();
let tile = id(&s.execute("shape.rectangle", &json!({"x": 150, "y": 60, "width": 10, "height": 10})).unwrap());
s.execute("object.pattern.make", &json!({"ids": [tile.0], "name": "Dots", "edit": false})).unwrap();
pair(&mut s, "static");
let inst = art_of_top(&mut s);
let undo = undo_len(&s);
let p = json!({"id": inst.0, "path": [], "fill": {"swatch": "Dots"}, "opacity": 50});
let e = s.execute("symbol.instance.setAppearance", &p).unwrap_err().to_string();
assert!(e.contains("fill on a symbol instance must be a solid color or none"), "{e}");
assert_eq!((overrides(&s, inst), undo_len(&s)), (0, undo), "nothing changes and no undo step");
// The pattern draws on a path.
let probe = square(&mut s, 110.0);
let before = page(&s);
s.execute("paint.setFill", &json!({"ids": [probe.0], "swatch": "Dots"})).unwrap();
assert!(page(&s) != before, "the pattern draws on a path");
// A pattern fill stored on the nested instance past the command draws nothing.
let before = page(&s);
let paint = s.doc().unwrap().doc.swatch("Dots").unwrap().paint.clone();
assert!(matches!(paint, Paint::Pattern { .. }), "{paint:?}");
s.edit("paint", |d, _| {
let o = vectorcraft_doc::symbol_overrides::SymbolOverride { path: vec![], fill: Some(paint), ..Default::default() };
d.node_mut(inst).ok_or(EngineError::NoNode(inst))?.extras_mut().symbol_overrides.push(o);
Ok(())
})
.unwrap();
assert!(page(&s) == before, "a pattern fill on a symbol instance draws nothing");
}
#[test]
fn null_takes_back_an_old_change_an_object_does_not_draw() {
let mut s = session();
let (a, b) = (square(&mut s, 10.0), square(&mut s, 60.0));
s.execute("object.blend.make", &json!({"ids": [a.0, b.0], "steps": 1})).unwrap();
let inst = art_of_top(&mut s);
// A change stored before the command refused one, as an older document may hold.
paint_root_red(&mut s, inst);
s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [], "fill": null})).unwrap();
assert!(node(&s, inst).extras().symbol_overrides[0].fill.is_none());
s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [], "stroke": null})).unwrap();
assert_eq!(overrides(&s, inst), 0);
}
#[test]
fn paths_type_groups_and_shaped_image_paint_take_a_fill_and_stroke() {
let set = |s: &mut Session, inst: NodeId, path: Value| {
s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": path, "fill": "#ff0000", "stroke": "#ff0000"})).unwrap()
};
// A group's own fill paints over its members.
let mut s = session();
let inst = pair(&mut s, "dynamic");
assert_eq!(set(&mut s, inst, json!([]))["overrides"], 1);
assert_eq!(pixel(&s, 30, 30), RED, "the group's own fill");
// A compound path, type and a clipping path.
let mut s = session();
let (a, b) = (square(&mut s, 10.0), square(&mut s, 60.0));
s.execute("object.compoundPath.make", &json!({"ids": [a.0, b.0]})).unwrap();
let inst = art_of_top(&mut s);
set(&mut s, inst, json!([]));
assert_eq!(pixel(&s, 30, 30), RED, "the compound path");
let mut s = session();
s.execute("text.create", &json!({"x": 10, "y": 40, "text": "Hi"})).unwrap();
let inst = art_of_top(&mut s);
set(&mut s, inst, json!([]));
let mut s = session();
let (a, b) = (square(&mut s, 10.0), square(&mut s, 20.0));
s.execute("object.clippingMask.make", &json!({"ids": [a.0, b.0]})).unwrap();
let inst = art_of_top(&mut s);
assert!(matches!(&node(&s, inst).kind, NodeKind::SymbolInstance { .. }));
set(&mut s, inst, json!([0]));
set(&mut s, inst, json!([]));
// An image whose fill has Convert to Shape: the fill paints 9 pt past its frame.
let mut s = session();
place_image(&mut s);
s.execute("appearance.addFill", &json!({})).unwrap();
s.execute("paint.setFill", &json!({"color": "#00ff00", "item": 0})).unwrap();
s.execute("effect.apply", &json!({"effect": "convertToShape.rectangle", "item": 0})).unwrap();
assert_eq!(pixel(&s, 44, 35), [0, 255, 0, 255]);
let inst = art_of_top(&mut s);
let e = s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [], "stroke": "#ff0000"})).unwrap_err().to_string();
assert!(e.contains("stroke cannot be set on an image without a Convert to Shape or Outline Object effect on it"), "{e}");
s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [], "fill": "#ff0000"})).unwrap();
assert_eq!(pixel(&s, 44, 35), RED, "the shaped fill takes the instance's color");
}
#[test]
fn redefining_the_symbol_keeps_the_changes_whose_objects_remain() {
let mut s = session();

Some files were not shown because too many files have changed in this diff Show More