diff --git a/crates/cad/Cargo.toml b/crates/cad/Cargo.toml index 2ea6dea4f..3e262b076 100644 --- a/crates/cad/Cargo.toml +++ b/crates/cad/Cargo.toml @@ -3,7 +3,7 @@ name = "vectorcraft-cad" version.workspace = true edition.workspace = true license.workspace = true -description = "VectorCraft CAD interchange: a hand-written ASCII DXF writer (R12 to 2018)" +description = "VectorCraft CAD interchange: a hand-written ASCII DXF writer (R12 to 2018) and an HP-GL/2 plot file reader" [lints] workspace = true diff --git a/crates/cad/src/hpgl.rs b/crates/cad/src/hpgl.rs new file mode 100644 index 000000000..5016e301f --- /dev/null +++ b/crates/cad/src/hpgl.rs @@ -0,0 +1,195 @@ +//! HP-GL import: a plotter file (HP-GL/2, `.plt`, `.hpgl`, `.hgl`, `.hpg`) as a document. Older +//! HP-GL files are read by the HP-GL/2 rules for the instructions the two share. +//! +//! The pen's moves become paths: each run drawn with the pen down is one stroked path in the +//! selected pen's color and width (0.35 mm unless PW sets another), with the line type as a dash +//! pattern and the line attributes as caps and joins. Circles, arcs, wedges, rectangles and Bézier +//! curves are drawn as curves (the chord angle isn't used). Filled rectangles, wedges and polygons +//! (polygon mode, FP) become filled paths, edged ones stroked paths. Plotter units (1016 to the +//! inch, y up) become points, through user units (SC, IP, IR) and the rotation (RO). The artboard +//! is the bounds of what the art paints, its strokes included. +//! +//! VectorCraft's own rules where the device decides: before the first SP the pen is pen 1 (a +//! device draws nothing until SP), and the hard-clip limits are 11 × 8.5 in from the origin, so +//! P2 lies there when the file sets no scaling points. SP0 draws nothing. Labels, symbol mode's symbols and raster +//! images (HP RTL) aren't drawn, the input window doesn't clip, hatched fills are filled solid, +//! and the art of every page goes on one artboard; each comes back as a warning. Printer escape +//! sequences and PJL lines around the instructions are skipped. +//! +//! - `lex`: the bytes as instructions, numbers, labels and PE data. +//! - `plot`: the instructions played on the pen, as shapes. + +mod lex; +mod plot; + +use std::sync::Arc; + +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, Dash, Document, FillLayer, LayerColor, Node, NodeId, NodeKind, StrokeLayer}; +use vectorcraft_geom::{Affine, PathData, Rect}; + +use crate::Imported; +use lex::Lexer; +use plot::Plot; + +/// Points per plotter unit. +const PT_PER_PLU: f64 = 72.0 / 1016.0; +/// The weight of a zero-width pen (one device dot), in points. +const THINNEST: f64 = 0.25; + +/// Instructions the reader knows and draws nothing for (their parameters are read and dropped). +const IGNORED: &[[u8; 2]] = &[ + *b"AC", *b"AD", *b"AF", *b"AH", *b"AP", *b"AS", *b"CA", *b"CF", *b"CM", *b"CP", *b"CS", *b"CT", *b"CV", *b"DC", *b"DI", *b"DL", *b"DP", *b"DR", + *b"DS", *b"DV", *b"EC", *b"ES", *b"FI", *b"FN", *b"FS", *b"GM", *b"IM", *b"IV", *b"KY", *b"LM", *b"LO", *b"MC", *b"MG", *b"MT", *b"NR", *b"OA", + *b"OC", *b"OD", *b"OE", *b"OF", *b"OG", *b"OH", *b"OI", *b"OL", *b"OP", *b"OS", *b"OT", *b"OW", *b"PB", *b"PP", *b"PS", *b"PT", *b"QL", *b"RF", + *b"RP", *b"SA", *b"SB", *b"SD", *b"SG", *b"SI", *b"SL", *b"SR", *b"SS", *b"ST", *b"SV", *b"TD", *b"TL", *b"TR", *b"UC", *b"VA", *b"VN", *b"VS", + *b"XT", *b"YT", +]; + +/// Instructions the reader plays. +const PLAYED: &[[u8; 2]] = &[ + *b"AA", *b"AR", *b"AT", *b"BL", *b"BP", *b"BR", *b"BZ", *b"CI", *b"CO", *b"CR", *b"DF", *b"DT", *b"EA", *b"EP", *b"ER", *b"EW", *b"FP", *b"FR", + *b"FT", *b"IN", *b"IP", *b"IR", *b"IW", *b"LA", *b"LB", *b"LT", *b"NP", *b"PA", *b"PC", *b"PD", *b"PE", *b"PG", *b"PM", *b"PR", *b"PU", *b"PW", + *b"RA", *b"RO", *b"RR", *b"RT", *b"SC", *b"SM", *b"SP", *b"UL", *b"WD", *b"WG", *b"WU", +]; + +/// Instructions a plot file starts with. +const FIRST: &[[u8; 2]] = &[ + *b"IN", *b"DF", *b"BP", *b"CO", *b"PS", *b"SP", *b"PU", *b"PD", *b"PA", *b"PR", *b"IP", *b"IR", *b"SC", *b"NP", *b"PC", *b"PW", *b"WU", *b"RO", + *b"QL", *b"MC", *b"PP", *b"TR", *b"CR", *b"LT", *b"LA", *b"FT", *b"VS", *b"FS", *b"AC", *b"PG", *b"PE", *b"DT", +]; + +/// Does `bytes` start like an HP-GL file? After any printer escapes and PJL lines (however long), +/// it must hold nothing but instructions the reader knows, the first one of those a plot file +/// starts with, up to the eighth instruction or 4 KB past the first. Their parameters must be +/// numbers, but for a comment's quoted string and the text, characters and encoded data of LB, BL, +/// WD, DT, SM and PE. An instruction the reader doesn't know counts when its numbers end with a +/// semicolon. +pub fn is_hpgl(bytes: &[u8]) -> bool { + holds_instructions(bytes, true) +} + +/// Does `bytes` hold HP-GL instructions as [`is_hpgl`] checks? When `strict` is false, any of them +/// may come first: a file picked by its extension may start with one a plot file seldom starts +/// with (`SI`, `DI`). Either way, at least one of the instructions checked must be one the reader +/// knows. +fn holds_instructions(bytes: &[u8], strict: bool) -> bool { + let mut lex = Lexer::new(bytes); + let mut terminator = lex::ETX; + let mut first = 0; + let mut any_known = false; + for n in 0..8 { + if n > 0 && lex.pos() > first + 4096 { + return any_known; + } + let Some(op) = lex.op(true) else { return n > 0 && any_known && lex.done() }; + if n == 0 { + first = lex.pos(); + } + if strict && n == 0 && !FIRST.contains(&op) { + return false; + } + let known = PLAYED.contains(&op) || IGNORED.contains(&op); + any_known |= known; + match &op { + b"CO" => { + if !lex.comment() { + return false; + } + } + b"LB" | b"BL" | b"WD" => lex.label(terminator), + b"DT" => { + terminator = lex.char().unwrap_or(lex::ETX); + lex.params(); + } + b"SM" => { + // The symbol character is read so it isn't taken for an instruction. + let _ = lex.char(); + } + b"PE" => lex.skip_pe(), + _ if known => lex.params(), + // An instruction the reader doesn't know (of an older or a device's own set) counts + // when numbers and a semicolon follow it. + _ => { + if !lex.closed_params() { + return false; + } + } + } + } + any_known +} + +/// Read an HP-GL file. It may start with any instruction the reader knows, or one of another set +/// that a semicolon ends; the rest of [`is_hpgl`]'s check applies, and at least one instruction the +/// reader knows must be among those it checks. +pub fn import(bytes: &[u8]) -> Result { + if !holds_instructions(bytes, false) { + return Err("not an HP-GL file".into()); + } + let mut lex = Lexer::new(bytes); + let mut plot = Plot::new(); + while !plot.full + && let Some(op) = lex.op(false) + { + plot.op(op, &mut lex); + } + Ok(build(plot.finish(lex.raster))) +} + +fn rgb(c: [u8; 3]) -> Paint { + Paint::solid(Color::rgb8(c[0], c[1], c[2])) +} + +/// The plot's shapes as paths on one layer, the artboard round what they paint. +fn build(plot: Plot) -> Imported { + // Plotter units, y up, to points, y down. + let flip = Affine::scale_non_uniform(PT_PER_PLU, -PT_PER_PLU); + let mut nodes: Vec = plot.shapes.iter().map(|s| node(s, flip)).collect(); + // The strokes' caps and miter joins included. + let finite = |r: &Rect| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()); + let frame = nodes.iter().filter_map(Node::visual_bounds).filter(finite).reduce(|a, b| a.union(b)).unwrap_or(Rect::new(0.0, 0.0, 612.0, 792.0)); + let mut doc = Document::new(frame.width().max(1.0), frame.height().max(1.0)); + doc.layers.clear(); + let shift = Affine::translate(-frame.origin().to_vec2()); + for n in &mut nodes { + n.id = doc.alloc_id(); + if let NodeKind::Path { path, .. } = &mut n.kind { + path.transform(shift); + } + } + let mut layer = Node::layer(doc.alloc_id(), "Layer 1", LayerColor::Preset(0)); + if let Some(c) = layer.children_mut() { + *c = nodes.into_iter().map(Arc::new).collect(); + } + doc.layers = vec![Arc::new(layer)]; + Imported { document: doc, warnings: plot.warnings } +} + +/// A shape as a path, its plotter units through `to_points`. +fn node(s: &plot::Shape, to_points: Affine) -> Node { + let mut path = s.path.clone(); + path.apply_affine(to_points); + let mut items = vec![]; + if let Some((c, _)) = s.fill { + items.push(AppearanceItem::Fill(FillLayer::new(rgb(c)))); + } + if let Some(st) = &s.stroke { + let mut layer = StrokeLayer::new(rgb(st.color), (st.width * PT_PER_PLU).max(THINNEST)); + (layer.cap, layer.join, layer.miter_limit) = (st.cap, st.join, st.miter); + layer.dash = st.dash.as_ref().map(|d| Dash { + pattern: d.pattern.iter().map(|v| v * PT_PER_PLU).collect(), + offset: d.offset * PT_PER_PLU, + align_corners: d.adaptive, + }); + items.push(AppearanceItem::Stroke(layer)); + } + let mut n = Node::path(NodeId(0), PathData::from_bezpath(&path), Appearance { items, ..Appearance::default() }); + if let (NodeKind::Path { rule, .. }, Some((_, r))) = (&mut n.kind, s.fill) { + *rule = r; + } + n +} + +#[cfg(test)] +mod tests; diff --git a/crates/cad/src/hpgl/lex.rs b/crates/cad/src/hpgl/lex.rs new file mode 100644 index 000000000..5025d07c6 --- /dev/null +++ b/crates/cad/src/hpgl/lex.rs @@ -0,0 +1,428 @@ +//! The bytes of an HP-GL file as instructions: two-letter mnemonics and the parameters that follow +//! them, read one at a time as the plot asks for them. Printer escape sequences (PCL, HP RTL, the +//! HP-GL device-control escapes) and PJL lines around and between the instructions are skipped, +//! with the data their escapes carry. + +/// The end-of-text character, the default label terminator. +pub(super) const ETX: u8 = 3; +const ESC: u8 = 0x1b; + +/// The most digits of a number that count: longer ones are read as their first digits. +const MAX_DIGITS: usize = 18; + +/// What the bytes being read hold. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Mode { + /// HP-GL instructions. + Hpgl, + /// PCL or HP RTL: escape sequences and their data, nothing to draw. + Pcl, + /// After a Universal Exit Language: PJL lines until a language starts. + Pjl, +} + +/// One item of a PE (Polyline Encoded) instruction. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) enum PeItem { + /// Select pen. + Pen(i64), + /// Rectangle mode: the following coordinates come in fours. + Rect, + /// A coordinate pair, after a pen-up flag (`up`) and an absolute flag (`absolute`). + Point { x: f64, y: f64, up: bool, absolute: bool }, +} + +/// The state of one PE instruction being read. +#[derive(Clone, Copy, Debug, Default)] +pub(super) struct Pe { + seven_bit: bool, + fraction_bits: u32, + up: bool, + absolute: bool, +} + +pub(super) struct Lexer<'a> { + b: &'a [u8], + at: usize, + mode: Mode, + /// The file holds HP RTL or PCL raster image data. + pub raster: bool, +} + +/// Is `c` a byte that separates numeric parameters (or white space between instructions)? +fn separator(c: u8) -> bool { + matches!(c, b' ' | b',' | b'\t' | b'\r' | b'\n' | 0) +} + +impl<'a> Lexer<'a> { + pub fn new(b: &'a [u8]) -> Self { + Self { b, at: 0, mode: Mode::Hpgl, raster: false } + } + + fn peek(&self) -> Option { + self.b.get(self.at).copied() + } + + /// Whether everything has been read. + pub fn done(&self) -> bool { + self.at >= self.b.len() + } + + /// How many bytes have been read. + pub fn pos(&self) -> usize { + self.at + } + + /// The next instruction's mnemonic, in upper case; `strict`: only white space, semicolons and + /// escape sequences may come before it (anything else gives `None`, as the end does). + pub fn op(&mut self, strict: bool) -> Option<[u8; 2]> { + loop { + let c = self.peek()?; + if c == ESC { + self.escape(); + continue; + } + match self.mode { + Mode::Pcl => { + self.at += 1; + continue; + } + Mode::Pjl => { + if separator(c) { + self.at += 1; + } else if c == b'@' { + self.pjl_line(); + } else { + self.mode = Mode::Hpgl; + } + continue; + } + Mode::Hpgl => {} + } + let next = self.b.get(self.at + 1).copied(); + if c.is_ascii_alphabetic() + && let Some(n) = next.filter(u8::is_ascii_alphabetic) + { + self.at += 2; + return Some([c.to_ascii_uppercase(), n.to_ascii_uppercase()]); + } + if strict && !(separator(c) || c == b';' || c < 0x20) { + return None; + } + self.at += 1; + } + } + + /// One `@PJL` line: an `ENTER LANGUAGE` command picks what follows. + fn pjl_line(&mut self) { + let rest = &self.b[self.at..]; + let end = rest.iter().position(|&c| c == b'\n').map_or(rest.len(), |n| n + 1); + // A command names its language within its first 256 bytes; longer lines aren't copied. + let line = String::from_utf8_lossy(&rest[..end.min(256)]).to_ascii_uppercase(); + if let Some(lang) = line.split_once("LANGUAGE").map(|(_, l)| l.trim_start_matches([' ', '=']).trim()) { + self.mode = if lang.starts_with("HPGL") { Mode::Hpgl } else { Mode::Pcl }; + } + self.at += end; + } + + /// An escape sequence at the current byte, with the data it carries. + fn escape(&mut self) { + self.at += 1; + let Some(c) = self.peek() else { return }; + self.at += 1; + match c { + // Universal Exit Language (`ESC%-12345X`) and the language switches (`ESC%#B` to HP-GL, + // `ESC%#A` to PCL or HP RTL). + b'%' => { + let (_, end) = self.value(); + match end { + Some(b'X') => self.mode = Mode::Pjl, + Some(b'B') => self.mode = Mode::Hpgl, + Some(b'A') => self.mode = Mode::Pcl, + _ => {} + } + } + // Reset: PCL follows, until a language switch. + b'E' => self.mode = Mode::Pcl, + // HP-GL device control: `ESC.` and a letter, with parameters up to a colon. + b'.' => { + let Some(kind) = self.peek() else { return }; + self.at += 1; + if !b"()YZBEJKLORUX".contains(&kind) { + let rest = &self.b[self.at..self.b.len().min(self.at + 80)]; + if let Some(n) = rest.iter().position(|&c| c == b':') + && rest[..n].iter().all(|c| c.is_ascii_digit() || matches!(c, b';' | b' ' | b',')) + { + self.at += n + 1; + } + } + } + // Parameterized PCL: a group character, then values each ending in a letter (upper case + // ends the sequence); `W`, `*b…V` (raster data by plane) and `&p…X` (transparent print + // data) are followed by that many bytes of data. Without a group character (`ESC(8U`, + // `ESC)0N`), one value and the letter ending it. + 0x21..=0x2f => { + let Some(group) = self.peek().filter(|g| (0x60..=0x7e).contains(g)) else { + self.value(); + return; + }; + self.at += 1; + loop { + let (v, end) = self.value(); + let Some(end) = end else { return }; + let upper = end.to_ascii_uppercase(); + if upper == b'W' || (c, group, upper) == (b'*', b'b', b'V') || (c, group, upper) == (b'&', b'p', b'X') { + let n = v.max(0.0).min((self.b.len() - self.at) as f64) as usize; + if (c, group) == (b'*', b'b') && n > 0 { + self.raster = true; + } + self.at += n; + } + if (c, group, end) == (b'*', b'r', b'A') { + self.raster = true; + } + if !end.is_ascii_lowercase() { + return; + } + } + } + // A two-character escape. + _ => {} + } + } + + /// A PCL value (`[+-]digits[.digits]`, any part missing) and the character that ends it; `None` + /// when that is not a letter or `@` (which is then left unread). + fn value(&mut self) -> (f64, Option) { + let v = self.number_here().unwrap_or(0.0); + match self.peek() { + Some(c) if c.is_ascii_alphabetic() || c == b'@' => { + self.at += 1; + (v, Some(c)) + } + _ => (v, None), + } + } + + /// A number starting at the current byte (`[+-]digits[.digits]`), `None` when it has no digit + /// (its sign and point are read all the same). + fn number_here(&mut self) -> Option { + let mut neg = false; + if let Some(c @ (b'+' | b'-')) = self.peek() { + neg = c == b'-'; + self.at += 1; + } + let (mut v, mut digits, mut scale) = (0.0f64, 0usize, 1.0f64); + let mut point = false; + while let Some(c) = self.peek() { + if c.is_ascii_digit() { + if digits < MAX_DIGITS { + if point { + scale /= 10.0; + v += f64::from(c - b'0') * scale; + } else { + v = v * 10.0 + f64::from(c - b'0'); + } + } else if !point { + // Past the digits that count, an integer part keeps its size. + v *= 10.0; + } + digits += 1; + } else if c == b'.' && !point { + point = true; + } else { + break; + } + self.at += 1; + } + let v = v.min(f64::MAX); + (digits > 0).then_some(if neg { -v } else { v }) + } + + /// The next numeric parameter of the instruction being read, or `None` at its end (a semicolon + /// ending it is read). + pub fn number(&mut self) -> Option { + loop { + let c = self.peek()?; + if separator(c) { + self.at += 1; + } else if c.is_ascii_digit() || matches!(c, b'+' | b'-' | b'.') { + if let Some(v) = self.number_here() { + return Some(v); + } + } else { + if c == b';' { + self.at += 1; + } + return None; + } + } + } + + /// Up to `out.len()` numeric parameters into `out` (the rest are read and dropped) → how many. + pub fn numbers(&mut self, out: &mut [f64]) -> usize { + let mut n = 0; + while let Some(v) = self.number() { + if let Some(slot) = out.get_mut(n) { + *slot = v; + } + n += 1; + } + n.min(out.len()) + } + + /// Read and drop the parameters of the instruction being read: numbers and quoted strings. + pub fn params(&mut self) { + loop { + while self.number().is_some() {} + if self.peek() != Some(b'"') { + return; + } + self.quoted(); + } + } + + /// Read and drop numeric parameters → whether a semicolon ended them (an instruction the reader + /// doesn't know, written out in full). + pub fn closed_params(&mut self) -> bool { + while self.number().is_some() {} + self.at > 0 && self.b.get(self.at - 1) == Some(&b';') + } + + /// A comment (CO): its quoted string or nothing, then the terminator or the end → whether it + /// is one. + pub fn comment(&mut self) -> bool { + while self.peek().is_some_and(separator) { + self.at += 1; + } + match self.peek() { + Some(b'"') => self.quoted(), + Some(b';') => { + self.at += 1; + true + } + None => true, + Some(_) => false, + } + } + + /// The text of a label, up to and including `terminator` (or the end). + pub fn label(&mut self, terminator: u8) { + let rest = &self.b[self.at..]; + self.at += rest.iter().position(|&c| c == terminator).map_or(rest.len(), |n| n + 1); + } + + /// A quoted string (CO): the parameters up to its closing quote, then the terminator → whether + /// there was one. + pub fn quoted(&mut self) -> bool { + while self.peek().is_some_and(separator) { + self.at += 1; + } + let found = self.peek() == Some(b'"'); + if found { + self.at += 1; + let rest = &self.b[self.at..]; + self.at += rest.iter().position(|&c| c == b'"').map_or(rest.len(), |n| n + 1); + } + if self.peek() == Some(b';') { + self.at += 1; + } + found + } + + /// The character right after the mnemonic (DT, SM), `None` when it is a semicolon or the end + /// (the semicolon is read). + pub fn char(&mut self) -> Option { + let c = self.peek()?; + self.at += 1; + (c != b';').then_some(c) + } + + /// The next item of a PE instruction, `None` at its end (its semicolon is read). + pub fn pe(&mut self, st: &mut Pe) -> Option { + loop { + let raw = self.peek()?; + let c = raw & 0x7f; + // Spaces, deletes, control characters and bytes 128 to 160 and 255 are ignored. + if raw <= 32 || raw == 127 || (128..=160).contains(&raw) || raw == 255 { + self.at += 1; + continue; + } + match c { + b';' => { + self.at += 1; + return None; + } + // Flags keep their meaning with the eighth bit set; digits are 63 and up (191 and up). + b':' | b'<' | b'>' | b'=' | b'7' | b'9' if raw < 63 || (0xb7..191).contains(&raw) => { + self.at += 1; + match c { + b':' => return self.pe_value(st).map(PeItem::Pen).or_else(|| self.end_pe()), + b'<' => st.up = true, + b'>' => match self.pe_value(st) { + Some(n) => st.fraction_bits = n.clamp(0, 52) as u32, + None => return self.end_pe(), + }, + b'=' => st.absolute = true, + b'7' => st.seven_bit = true, + _ => return Some(PeItem::Rect), + } + } + _ => { + let scale = 0.5f64.powi(st.fraction_bits as i32); + let (Some(x), Some(y)) = (self.pe_value(st), self.pe_value(st)) else { return self.end_pe() }; + let item = PeItem::Point { x: x as f64 * scale, y: y as f64 * scale, up: st.up, absolute: st.absolute }; + (st.up, st.absolute) = (false, false); + return Some(item); + } + } + } + } + + /// The rest of a PE instruction that can't be decoded: see [`Lexer::skip_pe`]. + fn end_pe(&mut self) -> Option { + self.skip_pe(); + None + } + + /// The data of a PE instruction, up to and including its semicolon, unread: its data is never + /// read as instructions. + pub fn skip_pe(&mut self) { + let rest = &self.b[self.at..]; + self.at += rest.iter().position(|&c| c == b';').map_or(rest.len(), |n| n + 1); + } + + /// One encoded PE number, `None` when the instruction ends inside it (or a byte that is no + /// digit comes first, which is read). + fn pe_value(&mut self, st: &Pe) -> Option { + let (base, low, high): (u64, std::ops::RangeInclusive, std::ops::RangeInclusive) = + if st.seven_bit { (32, 63..=94, 95..=126) } else { (64, 63..=126, 191..=254) }; + let (mut v, mut place) = (0u64, 1u64); + loop { + let raw = self.peek()?; + let c = if st.seven_bit { raw & 0x7f } else { raw }; + if raw <= 32 || raw == 127 || (128..=160).contains(&raw) || raw == 255 { + self.at += 1; + continue; + } + if c == b';' { + return None; + } + self.at += 1; + let (digit, last) = if low.contains(&c) { + (u64::from(c - 63), false) + } else if high.contains(&c) { + (u64::from(c - high.start()), true) + } else { + return None; + }; + v = v.saturating_add(digit.saturating_mul(place)); + place = place.saturating_mul(base); + if last { + // The low bit is the sign. + let n = i64::try_from(v >> 1).unwrap_or(i64::MAX); + return Some(if v & 1 == 1 { -n } else { n }); + } + } + } +} diff --git a/crates/cad/src/hpgl/plot.rs b/crates/cad/src/hpgl/plot.rs new file mode 100644 index 000000000..2ee70da75 --- /dev/null +++ b/crates/cad/src/hpgl/plot.rs @@ -0,0 +1,1331 @@ +//! The HP-GL/2 instructions played on a model of the plotter: the coordinate system (scaling +//! points, user units, rotation), the pen (position, up or down, number, width, color), the line +//! and fill attributes and the polygon buffer. What the pen draws is collected as shapes in +//! rotated plotter units, y up. + +use std::collections::HashMap; + +use kurbo::{Affine, BezPath, PathEl, Point, Shape as _, Vec2}; +use vectorcraft_doc::{LineCap, LineJoin}; +use vectorcraft_geom::FillRule; + +use super::lex::{ETX, Lexer, Pe, PeItem}; + +/// Plotter units per millimeter (0.025 mm each). +pub(super) const PLU_PER_MM: f64 = 40.0; +/// The hard-clip limits taken for P1 and P2 when the file sets none (plotter units): 11 × 8.5 in. +pub(super) const HARD_CLIP: Point = Point::new(11176.0, 8636.0); +/// The pen width after IN, in millimeters. +const DEFAULT_WIDTH_MM: f64 = 0.35; +/// The default line pattern length, in percent of the P1-P2 diagonal. +const DEFAULT_PATTERN: f64 = 4.0; +/// How far from the origin art may lie (plotter units, twice the range the format defines). +pub(super) const LIMIT: f64 = 16_777_216.0; +/// The widest pen (plotter units): 409.6 mm, the least a device must take before it draws its own +/// widest pen instead. +const MAX_WIDTH: f64 = 16_384.0; +/// The most objects an import makes. +const MAX_SHAPES: usize = 1 << 20; +/// The most path elements an import makes. +const MAX_ELEMENTS: usize = if cfg!(test) { 200_000 } else { 4_000_000 }; +/// The most pens a palette holds. +const MAX_PENS: i64 = 32_768; + +pub(super) const LABELS: &str = "labels were left out"; +pub(super) const SYMBOLS: &str = "symbols drawn at plotted points (symbol mode) were left out"; +pub(super) const RASTER: &str = "raster images in the file were left out"; +pub(super) const HATCHES: &str = "hatched and patterned fills are filled solid"; +pub(super) const WINDOW: &str = "the input window doesn't clip the art"; +pub(super) const PAGES: &str = "the art of later pages is drawn over the first page"; +pub(super) const FAR_AWAY: &str = "objects far outside the drawing were left out"; +pub(super) const TOO_MANY: &str = "the file has more than 1,048,576 objects: the rest are left out"; +pub(super) const TOO_MANY_POINTS: &str = "the file has more than 4,000,000 path points: the rest are left out"; + +/// The instructions polygon mode plays (the others are ignored there). +const POLYGON_MODE: &[[u8; 2]] = + &[*b"AA", *b"AR", *b"AT", *b"BP", *b"BR", *b"BZ", *b"CI", *b"CO", *b"DF", *b"IN", *b"PA", *b"PD", *b"PE", *b"PM", *b"PR", *b"PU", *b"RT"]; + +/// The colors of pens 0 to 7 until PC sets others. +const PALETTE: [[u8; 3]; 8] = [[255, 255, 255], [0, 0, 0], [255, 0, 0], [0, 255, 0], [255, 255, 0], [0, 0, 255], [255, 0, 255], [0, 255, 255]]; + +/// Line types 1 to 8: pen-down and pen-up lengths in percent of the pattern length. +const LINE_TYPES: [&[f64]; 8] = [ + &[0.0, 100.0], + &[50.0, 50.0], + &[70.0, 30.0], + &[80.0, 10.0, 0.0, 10.0], + &[70.0, 10.0, 10.0, 10.0], + &[50.0, 10.0, 10.0, 10.0, 10.0, 10.0], + &[70.0, 10.0, 0.0, 10.0, 0.0, 10.0], + &[50.0, 10.0, 0.0, 10.0, 10.0, 10.0, 0.0, 10.0], +]; + +/// A stroke as the pen draws it (lengths in plotter units). +#[derive(Clone, Debug, PartialEq)] +pub(super) struct Stroke { + pub color: [u8; 3], + pub width: f64, + pub cap: LineCap, + pub join: LineJoin, + pub miter: f64, + pub dash: Option, +} + +/// A line type as dashes (lengths in plotter units). +#[derive(Clone, Debug, PartialEq)] +pub(super) struct LinePattern { + /// Dash, gap, dash, gap…: an even number of lengths. + pub pattern: Vec, + /// How far into the pattern each line starts. + pub offset: f64, + /// The pattern stretches to fit each line, half its first dash at each end (adaptive line + /// types). + pub adaptive: bool, +} + +impl LinePattern { + /// The pattern of `percent` (pen-down and pen-up stretches, in turn, starting pen down) of + /// `length`. A pen-down stretch of zero is a dot, a vector one plotter unit long that the gap + /// after it gives up. An odd count repeats with its last pen-down stretch running into the + /// first one, which the pattern then starts within. + fn new(percent: &[f64], length: f64, adaptive: bool) -> Self { + let mut pattern: Vec = percent.iter().map(|p| p / 100.0 * length).collect(); + for pair in pattern.chunks_mut(2) { + if let [dash, rest @ ..] = pair + && *dash == 0.0 + { + *dash = 1.0; + if let Some(gap) = rest.first_mut() { + *gap = (*gap - 1.0).max(0.0); + } + } + } + let mut offset = 0.0; + if pattern.len() % 2 == 1 { + match pattern.len() { + // One pen-down stretch: a solid line. + 1 => pattern.push(0.0), + _ => { + let last = pattern.pop().unwrap_or(0.0); + if let Some(first) = pattern.first_mut() { + *first += last; + } + offset = last; + } + } + } + Self { pattern, offset, adaptive } + } +} + +/// What the plot draws: an outline with a fill, a stroke or both. +#[derive(Clone, Debug)] +pub(super) struct Shape { + pub path: BezPath, + pub fill: Option<([u8; 3], FillRule)>, + pub stroke: Option, +} + +/// A piece of a line the pen moves along, by its end. +#[derive(Clone, Copy, Debug)] +enum Piece { + Line(Point), + Curve(Point, Point, Point), +} + +impl Piece { + fn end(self) -> Point { + match self { + Self::Line(p) | Self::Curve(_, _, p) => p, + } + } + + fn push(self, bp: &mut BezPath) { + match self { + Self::Line(p) => bp.line_to(p), + Self::Curve(a, b, c) => bp.curve_to(a, b, c), + } + } +} + +/// User units: an SC instruction in force. +#[derive(Clone, Copy, Debug)] +enum Scaling { + /// The user ranges mapped onto P1 and P2; isotropic with the left and bottom percentages. + Fit { x0: f64, x1: f64, y0: f64, y1: f64, iso: Option<(f64, f64)> }, + /// P1's user coordinates and the plotter units per user unit. + Factor { x0: f64, fx: f64, y0: f64, fy: f64 }, +} + +/// How areas are filled. +#[derive(Clone, Copy, Debug, PartialEq)] +enum FillKind { + Solid, + /// The pen's color at this percentage, on white. + Shade(f64), + /// Hatching and patterns, filled solid. + Pattern, +} + +/// The polygon buffer: its outline for filling (every edge) and for edging (pen-down edges only). +#[derive(Clone, Debug, Default)] +struct Poly { + fill: BezPath, + edge: BezPath, + /// A subpolygon is open, from `start`; its last point. + open: bool, + start: Point, + last: Point, + /// The open subpolygon's points, and where its elements start in `fill` and `edge`. + points: usize, + marks: (usize, usize), + /// Where the edge outline ends (`None`: it has to move before drawing). + edge_at: Option, + /// After PM1 or a circle: the next move starts a subpolygon. + fresh: bool, +} + +impl Poly { + fn add(&mut self, a: Point, pieces: &[Piece], down: bool) { + let Some(end) = pieces.last().map(|p| p.end()) else { return }; + // The move after PM1 goes to the next subpolygon's first point with the pen up, whatever + // the pen's state (an arc's first chord aside): it is neither filled nor edged. + if std::mem::take(&mut self.fresh) && matches!(pieces, [Piece::Line(_)]) { + (self.start, self.last) = (end, end); + return; + } + if !self.open { + self.marks = (self.fill.elements().len(), self.edge.elements().len()); + self.fill.move_to(a); + (self.start, self.open, self.points) = (a, true, 1); + } + pieces.iter().for_each(|p| p.push(&mut self.fill)); + self.points += pieces.len(); + if down { + if self.edge_at != Some(a) { + self.edge.move_to(a); + } + pieces.iter().for_each(|p| p.push(&mut self.edge)); + self.edge_at = Some(end); + } else { + self.edge_at = None; + } + self.last = end; + } + + /// Close the open subpolygon, adding a point at its start when it ends elsewhere; the edge to + /// that point is drawn when `down`. A subpolygon of fewer than three points (that one + /// included) is neither filled nor edged. → The point added. + fn close(&mut self, down: bool) -> Option { + if !self.open { + return None; + } + let closure = self.last != self.start; + if self.points + usize::from(closure) < 3 { + self.fill.truncate(self.marks.0); + self.edge.truncate(self.marks.1); + } else { + if closure && down { + if self.edge_at != Some(self.last) { + self.edge.move_to(self.last); + } + self.edge.line_to(self.start); + } + self.fill.close_path(); + } + (self.open, self.edge_at) = (false, None); + closure.then_some(self.start) + } + + /// A closed outline of its own (a circle): the open subpolygon is closed with the pen up + /// first, and the next move starts a subpolygon. + fn closed(&mut self, path: &BezPath) { + self.close(false); + self.fill.extend(path.iter()); + self.edge.extend(path.iter()); + self.fresh = true; + } +} + +/// The line drawn so far with the pen down, not yet a shape. +struct Run { + path: BezPath, + stroke: Stroke, + start: Point, + end: Point, +} + +pub(super) struct Plot { + pub shapes: Vec, + pub warnings: Vec, + /// A budget ran out: nothing more is drawn. + pub full: bool, + elements: usize, + // The coordinate system. + p1: Point, + p2: Point, + scaling: Option, + /// User units to plotter units (identity when scaling is off). + user: Affine, + /// The RO rotation (rotated plotter units to where the art lies) and its angle. + rot: Affine, + ro: u16, + // The pen. + relative: bool, + down: bool, + /// The pen's position, in plotter units before rotation. + pos: Point, + /// The pen SP selected (`None`: none yet, which draws with pen 1). + pen: Option, + pens: i64, + colors: HashMap, + ranges: [(f64, f64); 3], + width: f64, + widths: HashMap, + relative_widths: bool, + // Line and fill attributes. + line_type: Option, + saved_line_type: Option, + pattern: f64, + pattern_mm: bool, + user_types: [Option>; 8], + cap: LineCap, + join: LineJoin, + miter: f64, + fill: FillKind, + // Polygons. + poly: Option, + buffer: Option, + run: Option, + terminator: u8, + /// Instructions of kinds the reader doesn't know. + pub unknown: usize, + /// How many shapes there were at the first page advance. + page_end: Option, +} + +impl Plot { + pub fn new() -> Self { + let mut p = Self { + shapes: vec![], + warnings: vec![], + full: false, + elements: 0, + p1: Point::ZERO, + p2: HARD_CLIP, + scaling: None, + user: Affine::IDENTITY, + rot: Affine::IDENTITY, + ro: 0, + relative: false, + down: false, + pos: Point::ZERO, + pen: None, + pens: 8, + colors: HashMap::new(), + ranges: [(0.0, 255.0); 3], + width: DEFAULT_WIDTH_MM * PLU_PER_MM, + widths: HashMap::new(), + relative_widths: false, + line_type: None, + saved_line_type: None, + pattern: DEFAULT_PATTERN, + pattern_mm: false, + user_types: Default::default(), + cap: LineCap::Butt, + join: LineJoin::Miter, + miter: 5.0, + fill: FillKind::Solid, + poly: None, + buffer: None, + run: None, + terminator: ETX, + unknown: 0, + page_end: None, + }; + p.init(); + p + } + + pub fn warn(&mut self, w: &str) { + if !self.warnings.iter().any(|x| x == w) { + self.warnings.push(w.to_string()); + } + } + + // ---------- defaults ---------- + + /// DF: the line and fill attributes, scaling, plotting mode and polygon mode as they start. + fn defaults(&mut self) { + self.flush(); + self.relative = false; + (self.line_type, self.saved_line_type, self.pattern, self.pattern_mm) = (None, None, DEFAULT_PATTERN, false); + self.user_types = Default::default(); + (self.cap, self.join, self.miter) = (LineCap::Butt, LineJoin::Miter, 5.0); + self.fill = FillKind::Solid; + (self.poly, self.buffer) = (None, None); + self.scaling = None; + self.ranges = [(0.0, 255.0); 3]; + self.terminator = ETX; + self.update_user(); + } + + /// IN: DF, and the pen up at the origin, default widths, palette and scaling points, no + /// rotation. The selected pen stays. + fn init(&mut self) { + self.defaults(); + (self.pos, self.down) = (Point::ZERO, false); + (self.width, self.relative_widths) = (DEFAULT_WIDTH_MM * PLU_PER_MM, false); + self.widths.clear(); + (self.rot, self.ro) = (Affine::IDENTITY, 0); + (self.p1, self.p2) = (Point::ZERO, HARD_CLIP); + self.pens = 8; + self.colors.clear(); + self.update_user(); + } + + // ---------- coordinates ---------- + + /// User units to plotter units for the scaling in force; scaling that maps everything onto one + /// point is ignored. + fn update_user(&mut self) { + let (p1, p2) = (self.p1, self.p2); + let user = match self.scaling { + None => Affine::IDENTITY, + Some(Scaling::Factor { x0, fx, y0, fy }) => { + Affine::translate(p1.to_vec2()) * Affine::scale_non_uniform(fx, fy) * Affine::translate((-x0, -y0)) + } + Some(Scaling::Fit { x0, x1, y0, y1, iso }) => { + let (w, h, ux, uy) = (p2.x - p1.x, p2.y - p1.y, x1 - x0, y1 - y0); + let (mut sx, mut sy, mut off) = (w / ux, h / uy, Vec2::ZERO); + if let Some((left, bottom)) = iso { + let s = sx.abs().min(sy.abs()); + let (extra_x, extra_y) = (w.abs() - ux.abs() * s, h.abs() - uy.abs() * s); + off = Vec2::new(extra_x * left / 100.0 * w.signum(), extra_y * bottom / 100.0 * h.signum()); + (sx, sy) = (s * sx.signum(), s * sy.signum()); + } + Affine::translate(p1.to_vec2() + off) * Affine::scale_non_uniform(sx, sy) * Affine::translate((-x0, -y0)) + } + }; + let det = user.determinant(); + self.user = if det.is_finite() && det != 0.0 && user.as_coeffs().iter().all(|c| c.is_finite()) { user } else { Affine::IDENTITY }; + } + + /// A point in current units, in plotter units. + fn to_plu(&self, x: f64, y: f64) -> Point { + self.user * Point::new(x, y) + } + + /// An increment in current units, in plotter units. + fn delta(&self, x: f64, y: f64) -> Vec2 { + self.to_plu(x, y) - self.to_plu(0.0, 0.0) + } + + /// The pen's position in current units. + fn pos_user(&self) -> Point { + self.user.inverse() * self.pos + } + + /// Plotter units (before rotation) to where the art lies. + fn dev(&self, p: Point) -> Point { + self.rot * p + } + + /// User units to where the art lies. + fn user_dev(&self) -> Affine { + self.rot * self.user + } + + /// The far corner of the hard-clip limits in the current orientation (their sides swap at 90 + /// and 270 degrees). + fn hard_clip(&self) -> Point { + if self.ro % 180 == 90 { Point::new(HARD_CLIP.y, HARD_CLIP.x) } else { HARD_CLIP } + } + + /// RO: plotter units rotated `angle` degrees (0, 90, 180 or 270) to where the art lies, shifted + /// so the hard-clip limits stay where they were. + fn rotation(angle: u16) -> Affine { + let (w, h) = (HARD_CLIP.x, HARD_CLIP.y); + match angle { + 90 => Affine::new([0.0, 1.0, -1.0, 0.0, w, 0.0]), + 180 => Affine::new([-1.0, 0.0, 0.0, -1.0, w, h]), + 270 => Affine::new([0.0, -1.0, 1.0, 0.0, 0.0, h]), + _ => Affine::IDENTITY, + } + } + + fn diagonal(&self) -> f64 { + (self.p2 - self.p1).hypot().max(1.0) + } + + // ---------- pens and attributes ---------- + + /// The palette entry the selected pen draws with (`None`: pen 0, no pen). + fn pen_index(&self) -> Option { + let n = self.pen.unwrap_or(1); + if n <= 0 { + return None; + } + Some((n - 1) % (self.pens - 1).max(1) + 1) + } + + fn color(&self, i: i64) -> [u8; 3] { + let default = usize::try_from(i).ok().and_then(|i| PALETTE.get(i)).copied().unwrap_or([0; 3]); + self.colors.get(&i).copied().unwrap_or(default) + } + + /// The stroke the selected pen draws (`None` for no pen). + fn stroke(&self) -> Option { + let i = self.pen_index()?; + let width = self.widths.get(&i).copied().unwrap_or(self.width).clamp(0.0, MAX_WIDTH); + let dash = self.line_type.filter(|k| *k != 0).map(|k| { + let index = k.unsigned_abs().clamp(1, 8) as usize - 1; + let length = if self.pattern_mm { self.pattern * PLU_PER_MM } else { self.pattern / 100.0 * self.diagonal() }; + match &self.user_types[index] { + Some(pct) => LinePattern::new(pct, length, k < 0), + None => LinePattern::new(LINE_TYPES[index], length, k < 0), + } + }); + Some(Stroke { color: self.color(i), width, cap: self.cap, join: self.join, miter: self.miter, dash }) + } + + /// The color the selected pen fills with (`None` for no pen). + fn fill_color(&mut self) -> Option<[u8; 3]> { + let c = self.color(self.pen_index()?); + match self.fill { + FillKind::Solid => Some(c), + FillKind::Pattern => { + self.warn(HATCHES); + Some(c) + } + FillKind::Shade(level) => { + let t = (level / 100.0).clamp(0.0, 1.0); + Some(c.map(|v| (255.0 - (255.0 - f64::from(v)) * t).round() as u8)) + } + } + } + + // ---------- output ---------- + + /// Count `n` path elements against the budget → whether they fit. + fn spend(&mut self, n: usize) -> bool { + self.elements += n; + if self.elements > MAX_ELEMENTS { + self.warn(TOO_MANY_POINTS); + self.full = true; + } + !self.full + } + + fn push(&mut self, shape: Shape) { + if shape.path.elements().len() < 2 { + return; + } + if self.shapes.len() >= MAX_SHAPES { + self.warn(TOO_MANY); + self.full = true; + return; + } + let b = shape.path.bounding_box(); + if ![b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite() && v.abs() <= LIMIT) { + return self.warn(FAR_AWAY); + } + self.shapes.push(shape); + } + + /// End the line being drawn. + fn flush(&mut self) { + let Some(run) = self.run.take() else { return }; + let mut els = run.path.into_elements(); + // Vectors of no length alone draw a dot: a vector one plotter unit long. + if els.len() == 1 { + els.push(PathEl::LineTo(run.start + Vec2::new(1.0, 0.0))); + } + // A line that ends where it started is closed, so its ends join. + if els.len() > 3 && run.end == run.start { + if matches!(els.last(), Some(PathEl::LineTo(p)) if *p == run.start) { + els.pop(); + } + els.push(PathEl::ClosePath); + } + self.push(Shape { path: BezPath::from_vec(els), fill: None, stroke: Some(run.stroke) }); + } + + /// A dot (a vector one plotter unit long) at `at`, drawn with the selected pen. + fn dot(&mut self, at: Point) { + if let Some(stroke) = self.stroke() { + let mut path = BezPath::new(); + path.move_to(at); + path.line_to(at + Vec2::new(1.0, 0.0)); + self.push(Shape { path, fill: None, stroke: Some(stroke) }); + } + } + + /// The pen moves from `a` along `pieces` (where the art lies), drawing when `down`; in polygon + /// mode the move goes into the polygon buffer. + fn trace(&mut self, a: Point, pieces: &[Piece], down: bool) { + // Only what goes into a path counts against the budget: outside polygon mode, moves with the + // pen up or with no pen (SP0) make nothing. + let draws = down && self.pen_index().is_some(); + if pieces.is_empty() || self.full || (!draws && self.poly.is_none()) || !self.spend(pieces.len()) { + return; + } + if let Some(poly) = &mut self.poly { + return poly.add(a, pieces, down); + } + let Some(stroke) = self.stroke() else { return }; + if self.line_type == Some(0) { + // Line type 0 draws a dot at each point given (an arc's or a curve's end). + self.flush(); + if let Some(p) = pieces.last() { + self.dot(p.end()); + } + return; + } + if self.run.as_ref().is_some_and(|r| r.stroke != stroke || r.end != a) { + self.flush(); + } + let run = self.run.get_or_insert_with(|| { + let mut path = BezPath::new(); + path.move_to(a); + Run { path, stroke, start: a, end: a } + }); + for p in pieces { + // A vector of no length adds nothing to a line (a line of nothing else is a dot). + if matches!(p, Piece::Line(b) if *b == run.end) { + continue; + } + p.push(&mut run.path); + run.end = p.end(); + } + } + + /// Move the pen to `target` (plotter units), drawing when it is down. + fn line_to(&mut self, target: Point) { + if !(target.x.is_finite() && target.y.is_finite()) { + return; + } + let (a, b) = (self.dev(self.pos), self.dev(target)); + let down = self.down; + self.trace(a, &[Piece::Line(b)], down); + self.pos = target; + } + + /// The curves of an arc around `center` (user units) from `start` through `sweep` degrees, + /// where the art lies, and its end (user units). `None` when it has no radius or sweep. + fn arc(&self, center: Point, start: Point, sweep: f64) -> Option<(Vec, Point)> { + let r = (start - center).hypot(); + let sweep = sweep.clamp(-32768.0, 32767.0).to_radians(); + if !(r > 0.0 && r.is_finite() && sweep != 0.0 && sweep.is_finite()) { + return None; + } + let a0 = (start - center).atan2(); + let arc = kurbo::Arc { center, radii: Vec2::new(r, r), start_angle: a0, sweep_angle: sweep, x_rotation: 0.0 }; + let m = self.user_dev(); + let pieces = arc + .append_iter(r * 1e-6) + .filter_map(|el| match el { + PathEl::CurveTo(a, b, c) => Some(Piece::Curve(m * a, m * b, m * c)), + PathEl::LineTo(p) => Some(Piece::Line(m * p)), + _ => None, + }) + .collect(); + Some((pieces, center + Vec2::from_angle(a0 + sweep) * r)) + } + + /// An arc from the pen around `center` (user units), drawn with the pen as it is. + fn arc_from_pen(&mut self, center: Point, sweep: f64) { + let start = self.pos_user(); + match self.arc(center, start, sweep) { + Some((pieces, end)) => { + let (a, down) = (self.dev(self.pos), self.down); + self.trace(a, &pieces, down); + self.pos = self.user * end; + } + None => { + let at = self.dev(self.pos); + let down = self.down; + self.trace(at, &[Piece::Line(at)], down); + } + } + } + + /// A closed outline through `pieces` from `a` (where the art lies). + fn outline(a: Point, pieces: &[Piece]) -> BezPath { + let mut path = BezPath::new(); + path.move_to(a); + pieces.iter().for_each(|p| p.push(&mut path)); + path.close_path(); + path + } + + /// A circle of `radius` (current units) around the pen, starting at 0 degrees (180 for a + /// negative radius), where the art lies. + fn circle(&self, radius: f64) -> Option { + let c = self.pos_user(); + let start = c + Vec2::new(radius, 0.0); + let (pieces, _) = self.arc(c, start, 360.0)?; + Some(Self::outline(self.user_dev() * start, &pieces)) + } + + /// A wedge of `radius` (current units) around the pen, from `start` through `sweep` degrees. + fn wedge(&self, radius: f64, start: f64, sweep: f64) -> Option { + let c = self.pos_user(); + let reference = if radius < 0.0 { 180.0 } else { 0.0 }; + let sweep = sweep.clamp(-360.0, 360.0); + let first = c + Vec2::from_angle((reference + start.clamp(-32768.0, 32767.0) % 360.0).to_radians()) * radius.abs(); + let m = self.user_dev(); + if sweep.abs() >= 360.0 { + let (pieces, _) = self.arc(c, first, sweep)?; + return Some(Self::outline(m * first, &pieces)); + } + let mut pieces = vec![Piece::Line(m * first)]; + if let Some((arc, _)) = self.arc(c, first, sweep) { + pieces.extend(arc); + } + (radius != 0.0).then(|| Self::outline(m * c, &pieces)) + } + + /// The rectangle from the pen to `corner` (plotter units), where the art lies. + fn rect(&self, corner: Point) -> BezPath { + let (a, b) = (self.pos_user(), self.user.inverse() * corner); + let m = self.user_dev(); + let pieces = [Point::new(b.x, a.y), b, Point::new(a.x, b.y)].map(|p| Piece::Line(m * p)); + Self::outline(m * a, &pieces) + } + + /// `path` as the polygon buffer, filled (`fill`) or edged. + fn buffer_and_draw(&mut self, path: Option, fill: bool) { + self.flush(); + if self.poly.is_some() { + return; + } + let Some(path) = path else { + return self.dot(self.dev(self.pos)); + }; + if !self.spend(path.elements().len()) { + return; + } + let mut poly = Poly::default(); + poly.closed(&path); + self.buffer = Some(poly); + if fill { self.fill_polygon(FillRule::EvenOdd) } else { self.edge_polygon() } + } + + /// FP: the polygon buffer filled (each fill is a copy of the buffer, counted against the + /// budget). + fn fill_polygon(&mut self, rule: FillRule) { + self.flush(); + let n = self.buffer.as_ref().map_or(0, |b| b.fill.elements().len()); + if n == 0 || !self.spend(n) { + return; + } + let Some(c) = self.fill_color() else { return }; + let Some(path) = self.buffer.as_ref().map(|b| b.fill.clone()) else { return }; + self.push(Shape { path, fill: Some((c, rule)), stroke: None }); + } + + /// EP: the pen-down edges of the polygon buffer stroked (counted as FP's are). + fn edge_polygon(&mut self) { + self.flush(); + let n = self.buffer.as_ref().map_or(0, |b| b.edge.elements().len()); + if n == 0 || !self.spend(n) { + return; + } + let Some(stroke) = self.stroke() else { return }; + let Some(path) = self.buffer.as_ref().map(|b| b.edge.clone()) else { return }; + self.push(Shape { path, fill: None, stroke: Some(stroke) }); + } + + // ---------- instructions ---------- + + /// Read and play the instruction `op`. + pub fn op(&mut self, op: [u8; 2], lex: &mut Lexer) { + if self.poly.is_some() && !POLYGON_MODE.contains(&op) { + return self.skip_op(op, lex); + } + match &op { + b"IN" | b"BP" | b"DF" | b"PU" | b"PD" | b"PA" | b"PR" | b"SP" => self.setup_op(op, lex), + b"PW" | b"WU" | b"NP" | b"PC" | b"CR" => self.pen_op(op, lex), + b"LT" | b"UL" => self.line_type_op(op, lex), + b"LA" | b"FT" | b"SC" => self.attribute_op(op, lex), + b"IP" | b"IR" | b"RO" | b"CI" => self.frame_op(op, lex), + b"AA" | b"AR" | b"AT" | b"RT" | b"BZ" | b"BR" => self.curve_op(op, lex), + b"EA" | b"ER" | b"RA" | b"RR" | b"EW" | b"WG" | b"PM" | b"FP" | b"EP" | b"PE" => self.area_op(op, lex), + _ => self.other_op(op, lex), + } + } + + /// An instruction polygon mode ignores: its parameters (or text) are read. + fn skip_op(&mut self, op: [u8; 2], lex: &mut Lexer) { + match &op { + b"LB" | b"BL" | b"WD" => lex.label(self.terminator), + b"DT" => { + lex.char(); + lex.params(); + } + b"SM" => { + lex.char(); + } + _ => lex.params(), + } + } + + /// Initialization, pen moves and pen selection. + fn setup_op(&mut self, op: [u8; 2], lex: &mut Lexer) { + let mut v = [0.0f64; 8]; + match &op { + b"IN" | b"BP" => { + lex.params(); + self.init(); + } + b"DF" => { + lex.params(); + self.defaults(); + } + b"PU" | b"PD" => { + self.down = op[1] == b'D'; + self.points(lex); + } + b"PA" | b"PR" => { + self.relative = op[1] == b'R'; + self.points(lex); + } + b"SP" => { + let n = lex.numbers(&mut v); + self.flush(); + self.pen = Some(if n == 0 { 0 } else { v[0].round().clamp(-1.0, 1e9) as i64 }); + } + _ => {} + } + } + + /// Pen widths, the palette and its colors. + fn pen_op(&mut self, op: [u8; 2], lex: &mut Lexer) { + let mut v = [0.0f64; 8]; + match &op { + b"PW" => { + let n = lex.numbers(&mut v); + self.flush(); + let width = |s: &Self, w: f64| if s.relative_widths { w / 100.0 * s.diagonal() } else { w * PLU_PER_MM }; + let default = if self.relative_widths { 0.1 } else { DEFAULT_WIDTH_MM }; + let w = width(self, if n == 0 { default } else { v[0].clamp(0.0, 32767.0) }); + if n >= 2 { + let pen = v[1].round().clamp(0.0, 1e9) as i64; + if pen > 0 && pen < self.pens.max(8) { + self.widths.insert(pen, w); + } + } else { + self.width = w; + self.widths.clear(); + } + } + b"WU" => { + let n = lex.numbers(&mut v); + self.flush(); + self.relative_widths = n > 0 && v[0].round() == 1.0; + self.width = if self.relative_widths { 0.1 / 100.0 * self.diagonal() } else { DEFAULT_WIDTH_MM * PLU_PER_MM }; + self.widths.clear(); + } + b"NP" => { + let n = lex.numbers(&mut v); + self.flush(); + if n == 0 { + self.pens = 8; + } else if v[0] >= 2.0 { + self.pens = i64::from((v[0].min(MAX_PENS as f64).ceil() as u32).next_power_of_two()).min(MAX_PENS); + } + } + b"PC" => { + let n = lex.numbers(&mut v); + self.flush(); + let pen = v[0].round().clamp(0.0, 1e9) as i64; + match n { + 0 => self.colors.clear(), + 1 => { + self.colors.remove(&pen); + } + // Two or three parameters: the instruction is ignored. + 2 | 3 => {} + _ if pen < MAX_PENS => { + let channel = |i: usize| { + let (b, w) = self.ranges[i]; + let t = if w == b { 0.0 } else { ((v[i + 1] - b) / (w - b)).clamp(0.0, 1.0) }; + (t * 255.0).round() as u8 + }; + let c = [channel(0), channel(1), channel(2)]; + self.colors.insert(pen, c); + } + _ => {} + } + } + b"CR" => { + let n = lex.numbers(&mut v); + if n == 0 { + self.ranges = [(0.0, 255.0); 3]; + } else if n >= 6 { + self.ranges = [(v[0], v[1]), (v[2], v[3]), (v[4], v[5])]; + } + } + _ => {} + } + } + + /// Line types and their patterns. + fn line_type_op(&mut self, op: [u8; 2], lex: &mut Lexer) { + let mut v = [0.0f64; 8]; + match &op { + b"LT" => { + let n = lex.numbers(&mut v); + self.flush(); + if n == 0 { + if self.line_type.is_some() { + self.saved_line_type = self.line_type; + } + self.line_type = None; + return; + } + let k = v[0].round().clamp(-100.0, 100.0) as i32; + if k == 99 { + if self.line_type.is_none() { + self.line_type = self.saved_line_type; + } + return; + } + if !(-8..=8).contains(&k) { + return; + } + // A pattern length of 0 or less, or a mode other than 0 and 1, makes the instruction ignored. + if (n >= 2 && !(v[1] > 0.0 && v[1] <= 32767.0)) || (n >= 3 && !matches!(v[2].round() as i32, 0 | 1)) { + return; + } + self.line_type = Some(k); + if n >= 2 { + self.pattern = v[1]; + } + if n >= 3 { + self.pattern_mm = v[2].round() == 1.0; + } + } + b"UL" => { + let mut gaps = [0.0f64; 21]; + let n = lex.numbers(&mut gaps); + self.flush(); + let index = gaps[0].round().abs(); + if n == 0 { + self.user_types = Default::default(); + } else if (1.0..=8.0).contains(&index) { + let slot = index as usize - 1; + let values = &gaps[1..n]; + let sum: f64 = values.iter().sum(); + self.user_types[slot] = if n == 1 { + None + } else if values.iter().all(|g| *g >= 0.0) && sum > 0.0 { + Some(values.iter().map(|g| g / sum * 100.0).collect()) + } else { + return; + }; + } + } + _ => {} + } + } + + /// Line ends and joins, the fill type and scaling. + fn attribute_op(&mut self, op: [u8; 2], lex: &mut Lexer) { + let mut v = [0.0f64; 8]; + match &op { + b"LA" => { + let n = lex.numbers(&mut v[..6]); + self.flush(); + if n == 0 { + (self.cap, self.join, self.miter) = (LineCap::Butt, LineJoin::Miter, 5.0); + } + for pair in v[..n - n % 2].chunks(2) { + let value = pair[1].round() as i32; + match (pair[0].round() as i32, value) { + (1, 1) => self.cap = LineCap::Butt, + (1, 2) => self.cap = LineCap::Square, + (1, 3 | 4) => self.cap = LineCap::Round, + (2, 1 | 2) => self.join = LineJoin::Miter, + (2, 4) => self.join = LineJoin::Round, + (2, 3 | 5 | 6) => self.join = LineJoin::Bevel, + (3, _) => self.miter = pair[1].clamp(1.0, 32767.0), + _ => {} + } + } + } + b"FT" => { + let n = lex.numbers(&mut v[..3]); + let kind = if n == 0 { 1 } else { v[0].round() as i32 }; + self.fill = match kind { + 1 | 2 => FillKind::Solid, + 10 => FillKind::Shade(if n >= 2 { v[1] } else { 100.0 }), + 3 | 4 | 11 | 21 | 22 => FillKind::Pattern, + _ => self.fill, + }; + } + b"SC" => { + let n = lex.numbers(&mut v[..7]); + self.flush(); + let kind = if n >= 5 { v[4].round() as i32 } else { 0 }; + self.scaling = match (n, kind) { + (0, _) => None, + (5, 2) if v[1] != 0.0 && v[3] != 0.0 => Some(Scaling::Factor { x0: v[0], fx: v[1], y0: v[2], fy: v[3] }), + (4..=7, 0 | 1) if v[0] != v[1] && v[2] != v[3] => { + let iso = (kind == 1).then(|| if n >= 7 { (v[5].clamp(0.0, 100.0), v[6].clamp(0.0, 100.0)) } else { (50.0, 50.0) }); + Some(Scaling::Fit { x0: v[0], x1: v[1], y0: v[2], y1: v[3], iso }) + } + _ => return, + }; + self.update_user(); + } + _ => {} + } + } + + /// The scaling points, the rotation and circles. + fn frame_op(&mut self, op: [u8; 2], lex: &mut Lexer) { + let mut v = [0.0f64; 8]; + match &op { + b"IP" | b"IR" => { + let n = lex.numbers(&mut v[..4]); + self.flush(); + // IR's percentages and the defaults are of the hard-clip limits in the current + // orientation. Scaling points past the art's range are held at its edge, so lengths + // taken from them stay finite. + let clip = self.hard_clip(); + let at = |x: f64, y: f64| { + let p = if op[1] == b'R' { Point::new(x / 100.0 * clip.x, y / 100.0 * clip.y) } else { Point::new(x, y) }; + Point::new(p.x.clamp(-LIMIT, LIMIT), p.y.clamp(-LIMIT, LIMIT)) + }; + match n { + 0 => (self.p1, self.p2) = (Point::ZERO, clip), + 2 | 3 => { + let p1 = at(v[0], v[1]); + (self.p1, self.p2) = (p1, p1 + (self.p2 - self.p1)); + } + 4 => { + let (p1, mut p2) = (at(v[0], v[1]), at(v[2], v[3])); + // A coordinate of P2 equal to P1's is one plotter unit more. + if p2.x == p1.x { + p2.x += 1.0; + } + if p2.y == p1.y { + p2.y += 1.0; + } + (self.p1, self.p2) = (p1, p2); + } + _ => {} + } + self.update_user(); + } + b"RO" => { + let n = lex.numbers(&mut v[..1]); + self.flush(); + let angle = if n == 0 { 0.0 } else { v[0].round().rem_euclid(360.0) }; + if [0.0, 90.0, 180.0, 270.0].contains(&angle) { + let ro = angle as u16; + let rot = Self::rotation(ro); + // The pen keeps its place on the page, its coordinates changing with the axes; + // P1 and P2 keep their coordinates, and the polygon buffer turns with the axes. + let turn = rot * self.rot.inverse(); + self.pos = rot.inverse() * (self.rot * self.pos); + if let Some(b) = &mut self.buffer { + b.fill.apply_affine(turn); + b.edge.apply_affine(turn); + } + (self.rot, self.ro) = (rot, ro); + } + } + b"CI" => { + let n = lex.numbers(&mut v[..2]); + if n == 0 { + return; + } + let path = self.circle(v[0]); + // In polygon mode the circle is a subpolygon of its own; the pen stays at its + // center. + if self.poly.is_some() { + if let Some(path) = path + && self.spend(path.elements().len()) + && let Some(poly) = &mut self.poly + { + poly.closed(&path); + } + return; + } + self.flush(); + // Line type 0 draws a dot at the center. + if self.line_type == Some(0) { + return self.dot(self.dev(self.pos)); + } + let Some(path) = path else { return self.dot(self.dev(self.pos)) }; + if self.spend(path.elements().len()) + && let Some(stroke) = self.stroke() + { + self.push(Shape { path, fill: None, stroke: Some(stroke) }); + } + } + _ => {} + } + } + + /// Arcs and Bézier curves from the pen. + fn curve_op(&mut self, op: [u8; 2], lex: &mut Lexer) { + let mut v = [0.0f64; 8]; + match &op { + b"AA" | b"AR" => { + if lex.numbers(&mut v[..4]) < 3 { + return; + } + let center = if op[1] == b'R' { self.pos_user() + Vec2::new(v[0], v[1]) } else { Point::new(v[0], v[1]) }; + self.arc_from_pen(center, v[2]); + } + b"AT" | b"RT" => { + if lex.numbers(&mut v[..5]) < 4 { + return; + } + let a = self.pos_user(); + let off = if op[0] == b'R' { a.to_vec2() } else { Vec2::ZERO }; + let (b, c) = (Point::new(v[0], v[1]) + off, Point::new(v[2], v[3]) + off); + self.three_point_arc(a, b, c); + } + b"BZ" | b"BR" => { + while let Some(x) = lex.number() { + let mut p = [x, 0.0, 0.0, 0.0, 0.0, 0.0]; + for slot in &mut p[1..] { + let Some(n) = lex.number() else { return }; + *slot = n; + } + let a = self.pos_user(); + let off = if op[1] == b'R' { a.to_vec2() } else { Vec2::ZERO }; + let [c1, c2, end] = [0, 2, 4].map(|i| Point::new(p[i], p[i + 1]) + off); + let m = self.user_dev(); + let (from, down) = (self.dev(self.pos), self.down); + self.trace(from, &[Piece::Curve(m * c1, m * c2, m * end)], down); + self.pos = self.user * end; + } + } + _ => {} + } + } + + /// Rectangles, wedges, polygon mode and encoded polylines. + fn area_op(&mut self, op: [u8; 2], lex: &mut Lexer) { + let mut v = [0.0f64; 8]; + match &op { + b"EA" | b"ER" | b"RA" | b"RR" => { + if lex.numbers(&mut v[..2]) < 2 { + return; + } + let corner = if op[1] == b'R' { self.pos + self.delta(v[0], v[1]) } else { self.to_plu(v[0], v[1]) }; + let path = self.rect(corner); + self.buffer_and_draw(Some(path), op[0] == b'R'); + } + b"EW" | b"WG" => { + if lex.numbers(&mut v[..4]) < 3 { + return; + } + let path = self.wedge(v[0], v[1], v[2]); + self.buffer_and_draw(path, op[0] == b'W'); + } + b"PM" => { + let n = lex.numbers(&mut v[..1]); + self.flush(); + // The point that closes a subpolygon moves the pen there: PM1 closes it with the + // pen up, PM2 with the pen as it is. + let mut closure = None; + match if n == 0 { 0 } else { v[0].round() as i32 } { + 0 if self.poly.is_none() => { + let at = self.dev(self.pos); + self.poly = Some(Poly { start: at, last: at, ..Poly::default() }); + self.buffer = None; + } + 1 => { + if let Some(p) = &mut self.poly { + closure = p.close(false); + p.fresh = true; + } + } + 2 => { + if let Some(mut p) = self.poly.take() { + closure = p.close(self.down); + self.buffer = Some(p); + } + } + _ => {} + } + if let Some(at) = closure { + self.pos = self.rot.inverse() * at; + } + } + b"FP" => { + let n = lex.numbers(&mut v[..1]); + self.fill_polygon(if n > 0 && v[0].round() == 1.0 { FillRule::NonZero } else { FillRule::EvenOdd }); + } + b"EP" => { + lex.params(); + self.edge_polygon(); + } + b"PE" => self.polyline_encoded(lex), + _ => {} + } + } + + /// Labels, comments, pages, the input window, and instructions that draw nothing. + fn other_op(&mut self, op: [u8; 2], lex: &mut Lexer) { + let mut v = [0.0f64; 4]; + match &op { + b"LB" | b"BL" | b"WD" => { + lex.label(self.terminator); + self.warn(LABELS); + } + b"DT" => { + self.terminator = lex.char().unwrap_or(ETX); + lex.params(); + } + b"SM" => { + // The symbol character is read so it isn't taken for an instruction; symbols + // aren't drawn. + if lex.char().is_some() { + self.warn(SYMBOLS); + } + } + b"CO" => { + lex.quoted(); + } + b"PG" | b"FR" => { + lex.params(); + self.flush(); + self.page_end.get_or_insert(self.shapes.len()); + // The next page starts with the pen up at the origin (FR also empties the + // polygon buffer). + (self.pos, self.down) = (Point::ZERO, false); + if op[0] == b'F' { + self.buffer = None; + } + } + b"IW" => { + if lex.numbers(&mut v) == 4 { + self.warn(WINDOW); + } + } + _ if super::IGNORED.contains(&op) => lex.params(), + _ => { + lex.params(); + self.unknown += 1; + } + } + } + + /// The coordinate pairs of PU, PD, PA and PR, plotted in the current mode. + fn points(&mut self, lex: &mut Lexer) { + while let Some(x) = lex.number() { + let Some(y) = lex.number() else { return }; + let target = if self.relative { self.pos + self.delta(x, y) } else { self.to_plu(x, y) }; + self.line_to(target); + if self.full { + return; + } + } + } + + /// AT and RT: the arc from `a` through `b` to `c` (user units). + fn three_point_arc(&mut self, a: Point, b: Point, c: Point) { + let d = 2.0 * (a.x * (b.y - c.y) + b.x * (c.y - a.y) + c.x * (a.y - b.y)); + let center = if a == c { + // Back to the start: the circle whose diameter ends at the start and the middle point. + Some(a.midpoint(b)) + } else if d.abs() > 1e-12 * (b - a).hypot2().max((c - a).hypot2()).max(1.0) { + let (a2, b2, c2) = (a.to_vec2().hypot2(), b.to_vec2().hypot2(), c.to_vec2().hypot2()); + Some(Point::new( + (a2 * (b.y - c.y) + b2 * (c.y - a.y) + c2 * (a.y - b.y)) / d, + (a2 * (c.x - b.x) + b2 * (a.x - c.x) + c2 * (b.x - a.x)) / d, + )) + } else { + None + }; + let Some(center) = center.filter(|p| p.x.is_finite() && p.y.is_finite()) else { + // Points in a line: a line to the end. + return self.line_to(self.user * c); + }; + let tau = std::f64::consts::TAU; + let angle = |p: Point| (p - center).atan2(); + let sweep = if a == c { + tau + } else { + let (s, t) = ((angle(c) - angle(a)).rem_euclid(tau), (angle(b) - angle(a)).rem_euclid(tau)); + if t < s { s } else { s - tau } + }; + self.arc_from_pen(center, sweep.to_degrees()); + if a != c { + self.pos = self.user * c; + } + } + + /// PE: encoded pen moves, pen selections and rectangles. + fn polyline_encoded(&mut self, lex: &mut Lexer) { + let mut st = Pe::default(); + let (mut rect, mut corner) = (false, false); + while let Some(item) = lex.pe(&mut st) { + if self.full { + continue; + } + match item { + PeItem::Pen(n) => { + if self.poly.is_none() { + self.flush(); + self.pen = Some(n.clamp(-1, 1_000_000_000)); + } + } + PeItem::Rect => (rect, corner) = (true, false), + PeItem::Point { x, y, up, absolute } => { + if up { + rect = false; + } + let target = if absolute { self.to_plu(x, y) } else { self.pos + self.delta(x, y) }; + if rect && corner { + // The second pair of a rectangle: its opposite corner, relative. It is filled + // as RR fills one, so polygon mode, which doesn't allow RR, ignores it. + let path = self.rect(self.pos + self.delta(x, y)); + self.buffer_and_draw(Some(path), true); + corner = false; + } else if rect { + self.down = false; + self.line_to(target); + corner = true; + } else { + self.down = !up; + self.line_to(target); + } + } + } + } + if rect { + self.down = false; + } + } + + /// The plot's end: the last line drawn, and notes on what wasn't read. + pub fn finish(mut self, raster: bool) -> Self { + self.flush(); + if let Some(n) = self.page_end + && self.shapes.len() > n + { + self.warn(PAGES); + } + if raster { + self.warn(RASTER); + } + if self.unknown > 0 { + let n = self.unknown; + self.warnings.push(format!( + "{n} instruction{} of kinds VectorCraft doesn't read {} skipped", + if n == 1 { "" } else { "s" }, + if n == 1 { "was" } else { "were" } + )); + } + self + } +} diff --git a/crates/cad/src/hpgl/tests.rs b/crates/cad/src/hpgl/tests.rs new file mode 100644 index 000000000..9a350707a --- /dev/null +++ b/crates/cad/src/hpgl/tests.rs @@ -0,0 +1,630 @@ +//! HP-GL import: units and the y axis, pens, runs, curves, filled and edged areas, polygon mode, +//! line types, scaling, rotation, encoded polylines, escapes around the instructions, what is +//! skipped with a warning, which files are taken to be HP-GL, and hostile files. + +use kurbo::{BezPath, PathEl, Point, Shape as _}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{AppearanceItem, Document, LineCap, LineJoin, Node, NodeKind, StrokeLayer}; +use vectorcraft_geom::FillRule; + +use super::plot::{FAR_AWAY, HATCHES, LABELS, PAGES, RASTER, SYMBOLS, TOO_MANY_POINTS, WINDOW}; +use super::*; + +/// Points per millimeter. +const MM: f64 = 72.0 / 25.4; + +fn plot(s: &str) -> Imported { + plot_bytes(s.as_bytes()) +} + +fn plot_bytes(b: &[u8]) -> Imported { + match import(b) { + Ok(i) => i, + Err(e) => panic!("{e}: {:?}", String::from_utf8_lossy(b)), + } +} + +/// Every object, in paint order. +fn leaves(doc: &Document) -> Vec { + let mut out = vec![]; + doc.walk(|n| { + if n.children().is_none() { + out.push(n.clone()); + } + }); + out +} + +fn bez(n: &Node) -> BezPath { + match &n.kind { + NodeKind::Path { path, .. } => path.to_bezpath(), + k => panic!("a path, got {k:?}"), + } +} + +fn stroke(n: &Node) -> StrokeLayer { + n.appearance.items.iter().find_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s.clone()) } else { None }).expect("a stroke") +} + +fn fill(n: &Node) -> Option { + n.appearance.items.iter().find_map(|i| if let AppearanceItem::Fill(f) = i { Some(f.paint.clone()) } else { None }) +} + +fn rule(n: &Node) -> FillRule { + match &n.kind { + NodeKind::Path { rule, .. } => *rule, + k => panic!("a path, got {k:?}"), + } +} + +fn rgb(r: u8, g: u8, b: u8) -> Paint { + Paint::solid(Color::rgb8(r, g, b)) +} + +/// The end points of `bp`'s elements. +fn ends(bp: &BezPath) -> Vec { + bp.elements().iter().filter_map(|e| e.end_point()).collect() +} + +fn near(a: Point, b: Point) -> bool { + (a - b).hypot() < 1e-6 +} + +fn assert_points(got: &[Point], want: &[(f64, f64)]) { + assert_eq!(got.len(), want.len(), "{got:?}"); + for (g, w) in got.iter().zip(want) { + assert!(near(*g, Point::new(w.0, w.1)), "{got:?} != {want:?}"); + } +} + +/// A PE number as the reference guide's encoding writes it (base 64, or base 32 for 7-bit data). +fn pe_number(v: i64, seven_bit: bool) -> Vec { + let mut n = if v >= 0 { 2 * v as u64 } else { 2 * v.unsigned_abs() + 1 }; + let (base, high) = if seven_bit { (32, 95) } else { (64, 191) }; + let mut out = vec![]; + while n >= base { + out.push(63 + (n % base) as u8); + n /= base; + } + out.push(high + n as u8); + out +} + +#[test] +fn plotter_units_become_points_with_y_down() { + // An inch right and an inch up, with the thinnest pen (0.25 pt): the artboard reaches 0.125 pt + // past the line at the corner's miter, and stops at the line's butt ends. + let i = plot("IN;SP1;PW0;PU0,0;PD1016,0,1016,1016;PU;"); + let doc = &i.document; + let r = doc.artboards[0].rect; + assert!((r.width() - 72.125).abs() < 1e-9 && (r.height() - 72.125).abs() < 1e-9, "{r:?}"); + let shapes = leaves(doc); + assert_eq!(shapes.len(), 1); + assert_points(&ends(&bez(&shapes[0])), &[(0.0, 72.0), (72.0, 72.0), (72.0, 0.0)]); + let s = stroke(&shapes[0]); + assert_eq!((s.width, s.paint.clone(), s.cap, s.join, s.miter_limit), (0.25, rgb(0, 0, 0), LineCap::Butt, LineJoin::Miter, 5.0)); + assert!(fill(&shapes[0]).is_none() && i.warnings.is_empty(), "{:?}", i.warnings); + + // The artboard holds what the strokes paint, the tip of a sharp miter join included. + let i = plot("IN;SP1;PW2;PD2032,0,0,1000;"); + let r = i.document.artboards[0].rect; + let painted = leaves(&i.document)[0].visual_bounds().unwrap(); + let path = bez(&leaves(&i.document)[0]).bounding_box(); + assert!(painted.x1 - path.x1 > 2.0 * MM, "the miter reaches past half the width: {painted:?} {path:?}"); + let inside = r.x0 <= painted.x0 + 1e-9 && r.y0 <= painted.y0 + 1e-9 && r.x1 >= painted.x1 - 1e-9 && r.y1 >= painted.y1 - 1e-9; + assert!(inside, "{r:?} {painted:?}"); +} + +#[test] +fn pens_pick_colors_and_widths() { + // Before any SP the pen is pen 1; SP0 draws nothing; pens past the palette wrap round it. + let i = plot("IN;PD0,0,100,0;PU;SP2;PD0,100,100,100;PU;SP0;PD0,200,100,200;PU;SP9;PD0,300,100,300;PU;SP3;PD0,400,100,400;PU;"); + let colors: Vec = leaves(&i.document).iter().map(|n| stroke(n).paint).collect(); + assert_eq!(colors, [rgb(0, 0, 0), rgb(255, 0, 0), rgb(255, 0, 0), rgb(0, 255, 0)]); + + // PC gives a pen a color in the color range; PC with the pen alone gives back its default. + let i = plot("IN;CR0,1,0,1,0,1;PC3,0,0,1;SP3;PD0,0,10,0;PU;PC3;PD0,10,10,10;PU;"); + let colors: Vec = leaves(&i.document).iter().map(|n| stroke(n).paint).collect(); + assert_eq!(colors, [rgb(0, 0, 255), rgb(0, 255, 0)]); + + // PW in millimeters for every pen, or for one; IN gives back 0.35 mm. + let i = plot("IN;SP1;PW1;PD0,0,10,0;PU;PW2,2;SP2;PD0,10,10,10;PU;SP1;PD0,20,10,20;PU;IN;SP1;PD0,30,10,30;"); + let widths: Vec = leaves(&i.document).iter().map(|n| stroke(n).width).collect(); + let want = [MM, 2.0 * MM, MM, 0.35 * MM]; + assert!(widths.iter().zip(want).all(|(a, b)| (a - b).abs() < 1e-9), "{widths:?}"); +} + +#[test] +fn each_run_with_the_pen_down_is_one_path() { + // Relative moves; a run that comes back to its start is closed; a pen-up move elsewhere starts + // another path, and one back to where the line ended continues it. + let i = plot("IN;SP1;PW0;PA0,0;PR;PD1016,0,0,1016,-1016,0,0,-1016;PU2032,0;PD0,1016;PU0,1016;PU0,-1016;PD508,0;PU0,500;PD0,500;"); + let shapes = leaves(&i.document); + assert_eq!(shapes.len(), 3, "{shapes:#?}"); + let NodeKind::Path { path: square, .. } = &shapes[0].kind else { panic!("a path") }; + assert_eq!((square.subpaths.len(), square.subpaths[0].anchors.len(), square.subpaths[0].closed), (1, 4, true)); + assert_eq!(ends(&bez(&shapes[1])).len(), 3, "the run went on from where it ended"); + assert_eq!(ends(&bez(&shapes[2])).len(), 2); + + // A pen-down move of no length draws a dot. + let i = plot("IN;SP1;PA500,500;PD;PA500,500;PU;"); + let shapes = leaves(&i.document); + assert_eq!(shapes.len(), 1); + let b = bez(&shapes[0]).bounding_box(); + assert!((b.width() - 72.0 / 1016.0).abs() < 1e-9, "{b:?}"); +} + +#[test] +fn circles_arcs_and_curves_are_drawn_as_curves() { + // A circle of an inch round the pen, which stays at its center (the next line starts there). + let i = plot("IN;SP1;PW0;PA2032,2032;CI1016;PD3048,2032;"); + let shapes = leaves(&i.document); + assert_eq!(shapes.len(), 2); + let c = bez(&shapes[0]); + assert!(c.elements().iter().any(|e| matches!(e, PathEl::CurveTo(..))) && matches!(c.elements().last(), Some(PathEl::ClosePath))); + // The curves stay within a thousandth of a point of the circle. + let b = c.bounding_box(); + assert!((b.width() - 144.0).abs() < 1e-3 && (b.height() - 144.0).abs() < 1e-3, "{b:?}"); + let line = ends(&bez(&shapes[1])); + assert!((line[0] - b.center()).hypot() < 1e-3, "{line:?} {b:?}"); + + // AA: a quarter turn counterclockwise round (0, 0) from (1016, 0) ends at (0, 1016), and the + // pen goes on from there. AR takes its center relative to the pen. + for s in ["IN;SP1;PW0;PA1016,0;PD;AA0,0,90;PA0,2032;", "IN;SP1;PW0;PA1016,0;PD;AR-1016,0,90;PA0,2032;"] { + let shapes = leaves(&plot(s).document); + assert_eq!(shapes.len(), 1, "{s}"); + let p = bez(&shapes[0]); + let pts = ends(&p); + // y down: (1016, 0) is at the bottom right, the arc's end on the left a quarter turn up. + let (start, end) = (pts[0], pts[pts.len() - 2]); + assert!(near(start, Point::new(72.125, 144.0)) && near(end, Point::new(0.125, 72.0)), "{s}: {pts:?}"); + assert!(near(*pts.last().unwrap(), Point::new(0.125, 0.0)), "{s}: {pts:?}"); + } + + // AT: the arc from the pen through a point to an end; points in a line give a line. + let shapes = leaves(&plot("IN;SP1;PW0;PA1016,0;PD;AT0,1016,-1016,0;").document); + let pts = ends(&bez(&shapes[0])); + let b = bez(&shapes[0]).bounding_box(); + assert!((b.width() - 144.0).abs() < 1e-6 && (b.height() - 72.0).abs() < 1e-6, "a half circle: {b:?}"); + assert!(near(pts[0], Point::new(144.125, 72.125)) && near(*pts.last().unwrap(), Point::new(0.125, 72.125)), "{pts:?}"); + let shapes = leaves(&plot("IN;SP1;PW0;PA0,0;PD;AT508,0,1016,0;").document); + assert_eq!(ends(&bez(&shapes[0])).len(), 2); + + // BZ and BR: cubic Béziers, BR's points relative to each curve's start. + for s in [ + "IN;SP1;PW0;PA0,0;PD;BZ0,1016,1016,1016,1016,0,1016,-1016,2032,-1016,2032,0;", + "IN;SP1;PW0;PA0,0;PD;BR0,1016,1016,1016,1016,0,0,-1016,1016,-1016,1016,0;", + ] { + let shapes = leaves(&plot(s).document); + let els = bez(&shapes[0]).elements().to_vec(); + assert_eq!(els.iter().filter(|e| matches!(e, PathEl::CurveTo(..))).count(), 2, "{s}"); + // The curves reach 762 plotter units (three quarters of their control points) up and down. + assert!(near(els.last().and_then(PathEl::end_point).unwrap(), Point::new(144.125, 54.125)), "{s}: {els:?}"); + } +} + +#[test] +fn rectangles_wedges_and_polygons_fill_and_edge() { + // RA fills, EA edges; neither moves the pen. + let i = plot("IN;SP2;PW0;PA0,0;RA1016,1016;EA1016,1016;PD508,0;"); + let shapes = leaves(&i.document); + assert_eq!(shapes.len(), 3); + assert_eq!((fill(&shapes[0]), shapes[0].appearance.items.len()), (Some(rgb(255, 0, 0)), 1)); + assert!(fill(&shapes[1]).is_none() && stroke(&shapes[1]).paint == rgb(255, 0, 0)); + assert!(near(ends(&bez(&shapes[2]))[0], Point::new(0.125, 72.125)), "the line starts where the pen was"); + + // WG fills a wedge, EW edges one; a whole turn is a circle. + let i = plot("IN;SP1;PA0,0;WG1016,0,90;EW1016,0,360;"); + let shapes = leaves(&i.document); + let wedge = bez(&shapes[0]); + assert!(fill(&shapes[0]).is_some() && ends(&wedge).len() >= 3); + let ring = bez(&shapes[1]).bounding_box(); + assert!((ring.width() - 144.0).abs() < 1e-6); + + // Polygon mode: two squares, one inside the other; FP fills even-odd by default, FP1 + // non-zero; EP edges only what was drawn with the pen down. + let poly = "IN;SP1;PW0;PA0,0;PM0;PD3048,0,3048,3048,0,3048,0,0;PM1;PU1016,1016;PD2032,1016;PU2032,2032;PD1016,2032,1016,1016;PM2;"; + let i = plot(&format!("{poly}FP;FP1;EP;")); + let shapes = leaves(&i.document); + assert_eq!(shapes.len(), 3); + assert_eq!((rule(&shapes[0]), rule(&shapes[1])), (FillRule::EvenOdd, FillRule::NonZero)); + let filled = bez(&shapes[0]); + assert_eq!(filled.elements().iter().filter(|e| matches!(e, PathEl::ClosePath)).count(), 2); + let edged = bez(&shapes[2]); + assert_eq!(edged.elements().iter().filter(|e| matches!(e, PathEl::MoveTo(..))).count(), 3, "the pen-up side isn't edged: {edged:?}"); + // Nothing is drawn in polygon mode itself. + assert!(leaves(&plot(poly).document).is_empty()); + + // A circle in polygon mode is a subpolygon of its own; CI and RA after PM2 work as before. + let i = plot("IN;SP1;PA2032,2032;PM0;CI1016;PM2;FP;"); + assert_eq!(leaves(&i.document).len(), 1); + + // Fill types: shading mixes the pen with white; hatching fills solid with a warning. + let i = plot("IN;SP5;PA0,0;FT10,50;RA100,100;FT3,10,45;RA200,200;"); + let shapes = leaves(&i.document); + assert_eq!((fill(&shapes[0]), fill(&shapes[1])), (Some(rgb(128, 128, 255)), Some(rgb(0, 0, 255)))); + assert_eq!(i.warnings, [HATCHES]); +} + +#[test] +fn line_types_become_dash_patterns() { + // A 10 mm pattern of line type 2 is 5 mm on, 5 mm off. + let i = plot("IN;SP1;LT2,10,1;PD0,0,4000,0;"); + let d = stroke(&leaves(&i.document)[0]).dash.expect("dashed"); + assert!(d.pattern.iter().all(|v| (v - 5.0 * MM).abs() < 1e-9) && d.pattern.len() == 2 && !d.align_corners, "{d:?}"); + // The default length is 4% of the P1-P2 diagonal; IP sets P1 and P2. + let i = plot("IN;IP0,0,3000,4000;SP1;LT3;PD0,0,4000,0;"); + let d = stroke(&leaves(&i.document)[0]).dash.expect("dashed"); + let total = 200.0 * 72.0 / 1016.0; + assert!((d.pattern[0] - 0.7 * total).abs() < 1e-9 && (d.pattern[1] - 0.3 * total).abs() < 1e-9, "{d:?}"); + // Adaptive types stretch to each line; a zero length is a dot one plotter unit long. + let i = plot("IN;SP1;LT-4,10,1;PD0,0,4000,0;"); + let d = stroke(&leaves(&i.document)[0]).dash.expect("dashed"); + assert!(d.align_corners && (d.pattern[2] - 72.0 / 1016.0).abs() < 1e-12, "{d:?}"); + // UL redefines a type; LT; draws solid and LT99 gives the type back. + let i = plot("IN;SP1;UL2,1,3;LT2,10,1;PD0,0,4000,0;PU;LT;PD0,100,4000,100;PU;LT99;PD0,200,4000,200;"); + let shapes = leaves(&i.document); + let d = stroke(&shapes[0]).dash.expect("dashed"); + assert!((d.pattern[0] - 2.5 * MM).abs() < 1e-9 && (d.pattern[1] - 7.5 * MM).abs() < 1e-9, "{d:?}"); + assert!(stroke(&shapes[1]).dash.is_none() && stroke(&shapes[2]).dash.is_some()); + // Line type 0 draws a dot at each point. + let i = plot("IN;SP1;LT0;PD0,0,1000,0,2000,0;"); + assert_eq!(leaves(&i.document).len(), 3); + // LA sets the caps, joins and miter limit. + let s = stroke(&leaves(&plot("IN;SP1;LA1,4,2,4,3,9;PD0,0,100,0;").document)[0]); + assert_eq!((s.cap, s.join, s.miter_limit), (LineCap::Round, LineJoin::Round, 9.0)); +} + +#[test] +fn scaling_maps_user_units_onto_p1_and_p2() { + // Anisotropic: 100 user units across an inch. + let size = |s: &str| { + let shapes = leaves(&plot(s).document); + bez(&shapes[0]).bounding_box().size() + }; + let s = size("IN;IP0,0,1016,2032;SC0,100,0,100;SP1;PD0,0,100,100;"); + assert!((s.width - 72.0).abs() < 1e-9 && (s.height - 144.0).abs() < 1e-9, "{s:?}"); + // Isotropic: the smaller of the two scales on both axes. + let s = size("IN;IP0,0,1016,2032;SC0,100,0,100,1;SP1;PU0,0;PD100,100;"); + assert!((s.width - 72.0).abs() < 1e-9 && (s.height - 72.0).abs() < 1e-9, "{s:?}"); + // Point factor: 10 plotter units per user unit. + let s = size("IN;SC0,10,0,10,2;SP1;PD0,0,101.6,50.8;"); + assert!((s.width - 72.0).abs() < 1e-9 && (s.height - 36.0).abs() < 1e-9, "{s:?}"); + // Without IP, P2 lies 11 × 8.5 in from the origin. + let s = size("IN;SC0,11,0,8.5;SP1;PD0,0,1,1;"); + assert!((s.width - 72.0).abs() < 1e-9 && (s.height - 72.0).abs() < 1e-9, "{s:?}"); + // SC; goes back to plotter units; scaling that makes no area is ignored. + let s = size("IN;SC0,1,0,1;SC;SC0,0,0,1;SP1;PD0,0,1016,1016;"); + assert!((s.width - 72.0).abs() < 1e-9, "{s:?}"); + // RO turns the coordinate system: x runs up. + let shapes = leaves(&plot("IN;RO90;SP1;PW0;PA0,0;PD1016,0;").document); + let b = bez(&shapes[0]).bounding_box(); + assert!(b.width() < 1e-9 && (b.height() - 72.0).abs() < 1e-9, "{b:?}"); +} + +#[test] +fn polyline_encoded_data_is_read() { + let mut f = b"IN;SP1;PW0;PE".to_vec(); + // Pen 2, a pen-up move to the absolute (1016, 0), then a relative line an inch up. + f.push(b':'); + f.extend(pe_number(2, false)); + f.extend(b"<="); + f.extend(pe_number(1016, false)); + f.extend(pe_number(0, false)); + f.extend(pe_number(0, false)); + f.extend(pe_number(1016, false)); + f.push(b';'); + let i = plot_bytes(&f); + let shapes = leaves(&i.document); + assert_eq!(shapes.len(), 1); + assert_eq!(stroke(&shapes[0]).paint, rgb(255, 0, 0)); + assert_points(&ends(&bez(&shapes[0])), &[(0.125, 72.0), (0.125, 0.0)]); + + // 7-bit data with two fractional bits: 4064 / 4 = 1016. + let mut f = b"IN;SP1;PW0;PE7>".to_vec(); + f.extend(pe_number(2, true)); + f.extend(pe_number(-4064, true)); + f.extend(pe_number(0, true)); + f.push(b';'); + let b = bez(&leaves(&plot_bytes(&f).document)[0]).bounding_box(); + assert!((b.width() - 72.0).abs() < 1e-9, "{b:?}"); + + // Data that can't be decoded ends the instruction at its semicolon: none of it is read as + // instructions. + let shapes = leaves(&plot("IN;SP1;PE#PD5000,5000;PD1016,0;").document); + assert_eq!(shapes.len(), 1); + assert!((bez(&shapes[0]).bounding_box().width() - 72.0).abs() < 1e-9); + + // Rectangle mode: a pen-up move, then a filled rectangle relative to it. + let mut f = b"IN;SP3;PE9".to_vec(); + for v in [100, 100, 1016, 508] { + f.extend(pe_number(v, false)); + } + f.push(b';'); + let shapes = leaves(&plot_bytes(&f).document); + assert_eq!(fill(&shapes[0]), Some(rgb(0, 255, 0))); + let b = bez(&shapes[0]).bounding_box(); + assert!((b.width() - 72.0).abs() < 1e-9 && (b.height() - 36.0).abs() < 1e-9, "{b:?}"); +} + +#[test] +fn printer_escapes_and_job_lines_are_skipped() { + let mut f = b"\x1b%-12345X@PJL JOB\r\n@PJL ENTER LANGUAGE=PCL\r\n\x1bE\x1b%0BIN;SP1;PW0;PD0,0,1016,0;PU;\x1b%0A".to_vec(); + // An HP RTL raster image whose data spells out instructions. + f.extend(b"\x1b*r1A\x1b*b11WPD9999,9999\x1b*rC"); + f.extend(b"\x1b%1BPD1016,1016;\x1b%-12345X@PJL EOJ\r\n"); + assert!(is_hpgl(&f)); + let i = plot_bytes(&f); + let shapes = leaves(&i.document); + // The pen went up and came down where the line ended, so the line goes on. + assert_eq!(shapes.len(), 1, "{shapes:#?}"); + assert_eq!(ends(&bez(&shapes[0])).len(), 3); + assert_eq!(i.warnings, [RASTER], "{:?}", i.warnings); + let r = i.document.artboards[0].rect; + assert!((r.width() - 72.125).abs() < 1e-9 && (r.height() - 72.125).abs() < 1e-9, "{r:?}"); + + // The device-control escapes of older plotters. + let f = b"\x1b.(\x1b.I81;;17:\x1b.N;19:IN;SP1;PD0,0,1016,0;\x1b.)"; + assert!(is_hpgl(f)); + assert_eq!(leaves(&plot_bytes(f).document).len(), 1); +} + +#[test] +fn labels_comments_and_unknown_instructions_are_skipped() { + // A label's text, up to its terminator (ETX, or the one DT sets), isn't read as instructions. + let i = plot("IN;SP1;LBPD9999,9999\x03DT*,1;LBPU0,0;PD5000,5000*CO\"PD8000,8000\";PD1016,0;PU;ZZ1,2;QQ;"); + let shapes = leaves(&i.document); + assert_eq!(shapes.len(), 1); + assert_eq!(i.warnings, [LABELS.to_string(), "2 instructions of kinds VectorCraft doesn't read were skipped".to_string()]); + // Symbol mode's symbols aren't drawn, with a warning; its lines are. + let i = plot("IN;SP1;SM*;PD100,0;SM;"); + assert_eq!((i.warnings.clone(), leaves(&i.document).len()), (vec![SYMBOLS.to_string()], 1)); + // Later pages and the input window are warned about. + let i = plot("IN;IW0,0,10,10;SP1;PD100,0;PU;PG;PD0,100;PG;"); + assert_eq!(i.warnings, [WINDOW, PAGES]); + // A page advance raises the pen at the origin: nothing joins the pages. + let i = plot("IN;SP1;PD100,0;PG;PA200,0;PD300,0;"); + let shapes = leaves(&i.document); + assert_eq!(shapes.len(), 2); + assert_eq!(ends(&bez(&shapes[1])).len(), 2, "the second page's line starts at (200, 0)"); + // A page advance at the end changes nothing. + assert!(plot("IN;SP1;PD100,0;PG;").warnings.is_empty()); + // A file that draws nothing opens as a letter-size page. + let i = plot("IN;SP1;PU100,100;"); + assert!(leaves(&i.document).is_empty() && i.document.artboards[0].rect.size() == kurbo::Size::new(612.0, 792.0)); +} + +#[test] +fn only_plot_files_are_taken_for_hpgl() { + for f in [ + &b"IN;SP1;PU0,0;PD100,100;"[..], + b"in;sp1;pu0,0;pd100,100;", + b";IN;\r\nSP1;\r\nPA100,100;", + b"\x03IN;", + b"SP1PD0,0,10,10PU", + b"IN", + b"CO\"made by hand\";IN;", + b"IN;SP1;DT*;LBhello world*PA0,0;SMx;PA1,1;SM;", + b"IN;SP1;PE<=\xbf\xbf;PU;", + b"\x1b%-12345X@PJL ENTER LANGUAGE=HPGL2\nIN;SP1;", + ] { + assert!(is_hpgl(f), "{:?}", String::from_utf8_lossy(f)); + } + for f in [ + &b""[..], + b"IN THE BEGINNING", + b"set terminal png\nplot sin(x)\n", + b"Paris, 10 to 12", + b"SPDX-License-Identifier: MIT", + b"CONTENTS", + b"CO \"a comment\";hello world", + b"DT*;LBtext*nope", + b"\x89PNG\r\n\x1a\n", + b"IN;SP1;PD0,0;hello world", + b"\x1b%-12345X@PJL ENTER LANGUAGE=PCL\n\x1bE\x1b*r1A", + ] { + assert!(!is_hpgl(f), "{:?}", String::from_utf8_lossy(f)); + assert_eq!(import(f).err().as_deref(), Some("not an HP-GL file"), "{:?}", String::from_utf8_lossy(f)); + } + // A comment needs no string; instructions of an older or a device's own set count when a + // semicolon ends them. + for f in [&b"CO;IN;SP1;PD0,0,10,10;"[..], b"IN;CO;SP1;PD0,0,10,10;", b"IN;SP1;VA;VS10;PU0,0;PD1016,0;", b"IN;GC5;SP1;PD0,0,10,0;"] { + assert!(is_hpgl(f), "{:?}", String::from_utf8_lossy(f)); + } + assert!(!is_hpgl(b"IN;GC5") && !is_hpgl(b"IN;Hello, world;")); + // A printer prolog of any length comes before the first instruction. + let long = [&b"\x1b%-12345X"[..], &b"@PJL COMMENT a job line of some length\r\n".repeat(400), b"\x1bE\x1b%0BIN;SP1;PD100,0;"].concat(); + assert!(long.len() > 16_000 && is_hpgl(&long)); + assert_eq!(leaves(&plot_bytes(&long).document).len(), 1); + // Instructions of any kind may follow the first eight, garbage included. + assert!(is_hpgl(b"IN;SP1;PU;PD;PU;PD;PU;PD;hello world")); +} + +/// A file read as HP-GL (picked by its extension) may start with any instruction the reader knows, +/// or one of another set that a semicolon ends; its content alone doesn't make it HP-GL. +#[test] +fn a_plot_file_may_start_with_any_instruction() { + for f in [&b"SI0.2,0.3;SP1;PA0,0;PD1016,0;"[..], b"DI1,0;SP1;PA0,0;PD1016,0;", b"GC5;SP1;PA0,0;PD1016,0;"] { + assert!(!is_hpgl(f), "{:?}", String::from_utf8_lossy(f)); + assert_eq!(leaves(&plot_bytes(f).document).len(), 1, "{:?}", String::from_utf8_lossy(f)); + } + for f in [&b"GC5"[..], b"SIhello world", b"set terminal png\n"] { + assert_eq!(import(f).err().as_deref(), Some("not an HP-GL file"), "{:?}", String::from_utf8_lossy(f)); + } + // Instructions of another set alone, however many or long, are not taken for HP-GL. + let long = [&b"GC"[..], "1".repeat(5000).as_bytes(), b";ZZ1;"].concat(); + for f in [&b"GC5;"[..], b"GC5;ZZ1;", b"GC5;ZZ1;YY2;XX3;WW4;VV5;UU6;TT7;", &long] { + assert_eq!(import(f).err().as_deref(), Some("not an HP-GL file"), "{:?}", String::from_utf8_lossy(f)); + } +} + +#[test] +fn escapes_that_carry_values_or_data_are_skipped_whole() { + // A reset leaves HP-GL until a language switch; symbol-set escapes carry a value and a letter. + for f in [ + &b"\x1bE\x1b(8U\x1b%1BIN;SP1;PD0,0,1016,0;"[..], + b"\x1bE\x1b(10U\x1b)0N\x1b%1BIN;SP1;PD0,0,1016,0;", + b"\x1bEHello\x1b%1BIN;SP1;PD0,0,1016,0;", + ] { + assert!(is_hpgl(f), "{:?}", String::from_utf8_lossy(f)); + assert_eq!(leaves(&plot_bytes(f).document).len(), 1, "{:?}", String::from_utf8_lossy(f)); + } + // A letter left over from an escape doesn't join the next mnemonic. + let i = plot_bytes(b"IN;SP1;\x1b(8UPD0,0,1016,0;"); + assert!(i.warnings.is_empty() && leaves(&i.document).len() == 1, "{:?}", i.warnings); + // Raster data by plane and transparent print data are skipped by their byte counts, however + // they read. + let line = |data_escape: &[u8]| { + let data = b"\x1b%1BPD0,5000;"; + let mut f = b"IN;SP1;PW0;PD0,0,0,1016;\x1b%0A".to_vec(); + f.extend(data_escape); + f.extend(data.len().to_string().as_bytes()); + f.extend(if data_escape.ends_with(b"b") { &b"V"[..] } else { b"X" }); + f.extend(data); + f.extend(b"\x1b*rC\x1b%1BPD0,0;"); + bez(&leaves(&plot_bytes(&f).document)[0]).bounding_box() + }; + for escape in [&b"\x1b*r1A\x1b*b"[..], b"\x1b&p"] { + let b = line(escape); + assert!((b.height() - 72.0).abs() < 1e-9, "{:?}: {b:?}", String::from_utf8_lossy(escape)); + } +} + +#[test] +fn polygon_mode_follows_its_own_rules() { + // The point after a circle starts a new subpolygon: the triangle after it doesn't reach back + // to the circle's center, filled or edged. + let i = plot("IN;SP1;PW0;PA1000,1000;PM0;CI200;PD2000,1000,2000,2000,1000,2000;PM2;FP;EP;"); + let shapes = leaves(&i.document); + assert_eq!(shapes.len(), 2); + for s in &shapes { + let p = bez(s); + let first = p.elements().iter().position(|e| matches!(e, PathEl::ClosePath)).unwrap_or(p.elements().len()); + let circle = BezPath::from_vec(p.elements()[..first].to_vec()).bounding_box(); + let center = circle.center(); + assert!(ends(&p).iter().all(|e| !near(*e, center)), "{p:?}"); + } + // PM1 closes with the pen up: EP draws two sides of the triangle, not the third; PM2 closes + // with the pen as it is. + let sides = |s: &str| { + let p = bez(&leaves(&plot(s).document)[0]); + p.elements().iter().filter(|e| matches!(e, PathEl::LineTo(..))).count() + }; + assert_eq!(sides("IN;SP1;PA0,0;PM0;PD1000,0,1000,1000;PM1;PM2;EP;"), 2); + assert_eq!(sides("IN;SP1;PA0,0;PM0;PD1000,0,1000,1000;PM2;EP;"), 3); + // The closing point moves the pen: a relative move after PM1 starts from the start. + let i = plot("IN;SP1;PA0,0;PM0;PD1000,0,1000,1000;PM1;PU;PR0,2000;PD1000,0,0,1000;PM2;FP;"); + let b = bez(&leaves(&i.document)[0]).bounding_box(); + assert!((b.height() - 3000.0 * PT_PER_PLU).abs() < 1e-9, "{b:?}"); + // A subpolygon of fewer than three points (the closing point counted) is neither filled nor + // edged. + assert!(leaves(&plot("IN;SP1;PA0,0;PM0;PD0,0;PM2;FP;EP;").document).is_empty()); + let i = plot("IN;SP1;PA0,0;PM0;PR0,0;PM1;PA;PD1000,0,0,1000,0,0;PM2;FP;"); + let filled = bez(&leaves(&i.document)[0]); + assert_eq!(filled.elements().iter().filter(|e| matches!(e, PathEl::ClosePath)).count(), 1, "{filled:?}"); + // Polygon mode ignores the instructions it doesn't allow: pens, scaling and labels. + let i = plot("IN;SP1;PA0,0;PM0;SP2;SC0,1,0,1;LBPD9999,9999\x03PD1016,0,1016,1016;PM2;FP;"); + let shapes = leaves(&i.document); + assert_eq!((shapes.len(), fill(&shapes[0])), (1, Some(rgb(0, 0, 0)))); + assert!((bez(&shapes[0]).bounding_box().width() - 72.0).abs() < 1e-9 && i.warnings.is_empty(), "{:?}", i.warnings); +} + +#[test] +fn rotation_keeps_the_pen_and_turns_the_defaults() { + // After RO90, IP's default P2 is the rotated hard-clip corner: the user square spans 8.5 in + // across its rotated x axis and 11 in up it, which lie up and across the page. + let i = plot("IN;RO90;IP;SC0,100,0,100;SP1;PW0;PA0,0;PD100,0,100,100,0,100,0,0;"); + let b = bez(&leaves(&i.document)[0]).bounding_box(); + assert!((b.width() - 792.0).abs() < 1e-9 && (b.height() - 612.0).abs() < 1e-9, "{b:?}"); + // IR's percentages are of the rotated limits too. + let i = plot("IN;RO90;IR0,0,100,100;SC0,1,0,1;SP1;PW0;PA0,0;PD1,1;"); + let b = bez(&leaves(&i.document)[0]).bounding_box(); + assert!((b.width() - 792.0).abs() < 1e-9 && (b.height() - 612.0).abs() < 1e-9, "{b:?}"); + // The pen stays where it was on the page: a line after RO starts where the one before ended. + let i = plot("IN;SP1;PW0;PD0,0,1016,0;PU;RO90;PD;PR1016,0;"); + let all = leaves(&i.document).iter().map(|n| bez(n).bounding_box()).reduce(|a, b| a.union(b)).unwrap(); + assert!((all.width() - 72.0).abs() < 1e-9 && (all.height() - 72.0).abs() < 1e-9, "{all:?}"); +} + +#[test] +fn line_type_zero_and_odd_patterns_follow_the_reference() { + // Line type 0: one dot for an arc (at its end) and one for a circle (at its center). + for s in ["IN;SP1;LT0;PA0,0;PD;AA1016,0,180;", "IN;SP1;LT0;PA0,0;CI500;"] { + let shapes = leaves(&plot(s).document); + assert_eq!(shapes.len(), 1, "{s}"); + assert!(bez(&shapes[0]).bounding_box().width() < 0.1, "{s}"); + } + // An odd number of gaps repeats with the last pen-down stretch running into the first. + let i = plot("IN;SP1;UL3,50,25,25;LT3,10,1;PD0,0,4000,0;"); + let d = stroke(&leaves(&i.document)[0]).dash.expect("dashed"); + assert!((d.pattern[0] - 7.5 * MM).abs() < 1e-9 && (d.pattern[1] - 2.5 * MM).abs() < 1e-9 && d.pattern.len() == 2, "{d:?}"); + assert!((d.offset - 2.5 * MM).abs() < 1e-9, "{d:?}"); + // One pen-down stretch is a solid line; a dot keeps the pattern's length. + let d = stroke(&leaves(&plot("IN;SP1;UL3,100;LT3,10,1;PD0,0,4000,0;").document)[0]).dash.expect("dashed"); + assert_eq!(d.pattern[1], 0.0); + let d = stroke(&leaves(&plot("IN;SP1;LT4,10,1;PD0,0,4000,0;").document)[0]).dash.expect("dashed"); + assert!((d.pattern.iter().sum::() - 10.0 * MM).abs() < 1e-9, "{d:?}"); +} + +#[test] +fn pen_colors_ignore_two_or_three_parameters() { + let i = plot("IN;PC3,0,0,255;PC3,255;PC3,255,0;SP3;PD0,0,10,0;"); + assert_eq!(stroke(&leaves(&i.document)[0]).paint, rgb(0, 0, 255)); +} + +#[test] +fn hostile_files_import_without_panics() { + let mut cases: Vec> = vec![ + // Coordinates past any plotter's range, and numbers with too many digits. + b"IN;SP1;PD99999999999999999999999999999,0;PU0,0;PD1016,1016;".to_vec(), + b"IN;SP1;PD0.000000000000000000000000001,1e999,--,++,..;".to_vec(), + // Unterminated labels, comments and encoded polylines. + b"IN;SP1;LBno end".to_vec(), + b"IN;CO\"no end".to_vec(), + b"IN;SP1;PE<=\xff\xff\xff\xfe\xfe".to_vec(), + b"IN;SP1;PE>\x7e\x7e\x7e\x7e\x7e\x7e\x7e\x7e\x7e\x7e\x7e\x7e\xfe\x40\xc0;".to_vec(), + // Arcs and wedges of every degenerate kind, with huge sweeps. + b"IN;SP1;PD;AA0,0,32767;AR0,0,90;AT0,0,0,0;RT1,1,2,2;CI0;CI-5;WG0,0,0;EW5,99999,-99999;WG-5,0,360;CI99999999999999;".to_vec(), + // Scaling onto one point, rotations the format doesn't define, huge palettes and pens. + b"IN;IP5,5,5,5;SC0,1,0,1,1;SP1;PD1,1;SC0,0,0,0,2;RO45;NP99999999;SP99999999999;PC99999999,1,2,3;PW99999,99999;PD2,2;".to_vec(), + // Polygon mode left open, PM1 and PM2 without PM0, FP and EP with nothing buffered. + b"IN;SP1;FP;EP;PM1;PM2;PM0;PD10,10;PM1;PM1;PM1;CI5;PE<\xc0\xc0;".to_vec(), + // Escapes cut short, and data lengths past the end. + b"\x1b%-12345X@PJL".to_vec(), + b"IN;SP1;PD0,0;\x1b*b99999999999W".to_vec(), + b"IN;\x1b".to_vec(), + b"IN;\x1b.".to_vec(), + ]; + // A job line with no end. + cases.push([&b"\x1b%-12345X@PJL "[..], &b"x".repeat(1 << 20)].concat()); + // Bytes of every value after a valid start. + cases.push([&b"IN;SP1;PD"[..], &(0..=255u8).collect::>()].concat()); + for f in &cases { + let r = std::panic::catch_unwind(|| import(f)); + assert!(r.is_ok(), "{:?}", String::from_utf8_lossy(f)); + } + let far = import(&cases[0]).unwrap(); + assert!(far.warnings.iter().any(|w| w == FAR_AWAY), "{:?}", far.warnings); + assert_eq!(leaves(&far.document).len(), 1, "the line in range is kept"); + + // Past the budget for path points, the rest is left out with a warning; pen-up moves, which + // make no path, don't count. + let many = format!("IN;SP1;PR;PD{}", "1,0,".repeat(300_000)); + let i = plot(&many); + assert!(i.warnings.iter().any(|w| w == TOO_MANY_POINTS), "{:?}", i.warnings); + // Scaling points past any range give relative pen widths no wider than the widest pen. + let i = plot("IN;IP-99999999999999999999,0,99999999999999999999,0;WU1;PW32767;SP1;PD100,0;"); + let r = i.document.artboards[0].rect; + assert!(r.width().is_finite() && r.height() <= 16384.0 * 72.0 / 1016.0 + 1e-6, "{r:?}"); + assert!((stroke(&leaves(&i.document)[0]).width - 16384.0 * 72.0 / 1016.0).abs() < 1e-9); + + // Each fill of the polygon buffer is a copy of it, and counts. + let big = format!("IN;SP1;PM0;PR;PD{}PM2;{}", "1,1,".repeat(1000), "FP;EP;".repeat(1000)); + let i = plot(&big); + assert!(i.warnings.iter().any(|w| w == TOO_MANY_POINTS) && leaves(&i.document).len() < 400, "{:?}", i.warnings); + for pen in ["SP1;PU", "SP0;PD"] { + let moves = format!("IN;{pen}{};SP1;PU0,0;PD1016,0;", "1,0,".repeat(300_000)); + let i = plot(&moves); + assert!(i.warnings.is_empty() && leaves(&i.document).len() == 1, "{pen}: {:?}", i.warnings); + } +} diff --git a/crates/cad/src/lib.rs b/crates/cad/src/lib.rs index 603521160..525e9b673 100644 --- a/crates/cad/src/lib.rs +++ b/crates/cad/src/lib.rs @@ -1,4 +1,5 @@ -//! CAD interchange: a hand-written ASCII DXF writer (versions R12 to 2018) and reader. +//! CAD interchange: a hand-written ASCII DXF writer (versions R12 to 2018) and reader, and an +//! HP-GL/2 plot file reader. //! //! [`export`] writes the visible art of a document into one DXF drawing: each layer becomes a DXF //! layer (hidden ones switched off, locked ones locked, non-printing ones not plotted), paths @@ -11,13 +12,16 @@ //! clipping) is approximated or left out, and each such loss comes back as a warning. //! //! [`import()`] reads an ASCII DXF drawing (any version) into a document: see [`import`]. +//! [`hpgl::import`] reads an HP-GL plotter file: see [`hpgl`]. //! //! - `aci`: the 256-colour index palette and the nearest index of a colour. //! - `writer`: group codes, handles and the file's sections, tables and objects. //! - `scene`: the document walk that turns objects into entities. //! - `import`: the reader, entities to art, blocks to symbols. +//! - `hpgl`: the HP-GL reader, the pen's moves to paths. mod aci; +pub mod hpgl; pub mod import; mod scene; mod writer; diff --git a/crates/doc/src/appearance/profile.rs b/crates/doc/src/appearance/profile.rs index 088796173..407780177 100644 --- a/crates/doc/src/appearance/profile.rs +++ b/crates/doc/src/appearance/profile.rs @@ -26,11 +26,16 @@ impl WidthProfile { pub fn at(&self, t: f64) -> (f64, f64) { Self::at_points(&self.points, t) } - /// [`Self::at`] of a profile's points (a preset's or a saved profile's). Each side runs through - /// its points on a monotone cubic curve (Fritsch–Carlson): it flows through a width point - /// without a kink, turns flat at a point wider or narrower than both neighbours, and never - /// goes past either point of the span it is in (#5714). Points on one straight line stay on - /// it. A step (two points at one position, [`Self::around`]) ends the curve on each side of it. + /// [`Self::at`] of a profile's points (a preset's or a saved profile's). + /// + /// Each side runs through its points on a cubic Hermite curve, its slope at each one the + /// weighted harmonic mean of the secants either side (Fritsch–Carlson). That is C1 continuous + /// and monotone between points, so it never overshoots them, a point eases into its neighbours + /// instead of making a corner, and moving one reshapes the curve as far as its neighbours' + /// neighbours (their slopes depend on it). The ends of a run use the one-sided PCHIP slope + /// from its first two secants, or the secant when it has only two points, so a two-point + /// profile stays a straight taper and the lens is a parabola. Two points at one `t` are still + /// a step: each run either side ends independently at it. pub fn at_points(p: &[(f64, f64, f64)], t: f64) -> (f64, f64) { if p.is_empty() { return (1.0, 1.0); @@ -71,9 +76,10 @@ impl WidthProfile { // Two swells between narrow necks. ProfilePreset { id: "wave", label: "Wave", points: &[(0.0, 0.3, 0.3), (0.25, 1.0, 1.0), (0.5, 0.3, 0.3), (0.75, 1.0, 1.0), (1.0, 0.3, 0.3)] }, ]; - /// The widest the stroke gets, as a multiple of its weight, 1 for a profile without points. Each - /// side stays between the widths of the two points of each span ([`Self::at_points`]), but the - /// two sides needn't peak together, so a span counts its wider left and its wider right. + /// The widest the stroke gets, as a multiple of its weight, 1 for a profile without points. + /// Each side's smooth curve stays between the widths of the two points of each span + /// ([`Self::at_points`]), but the two sides needn't peak together, so a span counts its wider + /// left and its wider right. pub fn widest(&self) -> f64 { let p = &self.points; if p.is_empty() { @@ -249,11 +255,27 @@ impl AngleProfile { /// How close two width points' positions are to make a step ([`WidthProfile::around`]). const STEP: f64 = 1e-7; +/// The one-sided three-point PCHIP slope at the start of a run, from the first two secants. +/// A sign change caps the slope, and a slope that turns back on the first secant is flattened. +/// Runs of only two points use their secant instead (see [`tangent`]). +fn end_slope(h0: f64, h1: f64, d0: f64, d1: f64) -> f64 { + let m = ((2.0 * h0 + h1) * d0 - h0 * d1) / (h0 + h1); + if !m.is_finite() || m * d0 <= 0.0 { + return 0.0; + } + if d0 * d1 < 0.0 { + return if d0 > 0.0 { m.min(3.0 * d0) } else { m.max(3.0 * d0) }; + } + m +} + /// The slope of one side (`v` reads it from a point) of width profile `p` at its point `j`, in /// width per unit of position: Fritsch–Carlson's choice for a monotone cubic curve. It is 0 where -/// the point is wider or narrower than both neighbours, the weighted harmonic mean of the slopes -/// of the spans on either side elsewhere (never more than three times either, so no span -/// overshoots its points), and the span's own slope at an end of the profile or beside a step. +/// the point is wider or narrower than both neighbours, and the weighted harmonic mean of the +/// slopes of the spans on either side elsewhere (never more than three times either, so no span +/// overshoots its points). The ends of a run take the one-sided three-point PCHIP slope, or their +/// secant when the run has only two points; a step ends the run on each side, so the points either +/// side of it do not see across it. fn tangent(p: &[(f64, f64, f64)], j: usize, v: fn(&(f64, f64, f64)) -> f64) -> f64 { let Some(q) = p.get(j) else { return 0.0 }; let before = j.checked_sub(1).and_then(|k| p.get(k)).filter(|a| q.0 - a.0 > STEP); @@ -268,8 +290,18 @@ fn tangent(p: &[(f64, f64, f64)], j: usize, v: fn(&(f64, f64, f64)) -> f64) -> f let (h0, h1) = (q.0 - a.0, b.0 - q.0); 3.0 * (h0 + h1) / ((2.0 * h1 + h0) / d0 + (h1 + 2.0 * h0) / d1) } - (Some(a), None) => slope(a, q), - (None, Some(b)) => slope(q, b), + // The end of a run: its previous two points set a one-sided three-point slope, or its + // secant when the run has only two points (the point before `a` is on the other side of a + // step, or does not exist). + (Some(a), None) => { + let Some(prev) = j.checked_sub(2).and_then(|k| p.get(k)).filter(|z| a.0 - z.0 > STEP) else { return slope(a, q) }; + end_slope(q.0 - a.0, a.0 - prev.0, slope(a, q), slope(prev, a)) + } + // The start of a run, symmetrically. + (None, Some(b)) => { + let Some(next) = p.get(j + 2).filter(|z| z.0 - b.0 > STEP) else { return slope(q, b) }; + end_slope(b.0 - q.0, next.0 - b.0, slope(q, b), slope(b, next)) + } (None, None) => 0.0, } } @@ -424,12 +456,12 @@ mod tests { #[test] fn widths_run_smoothly_through_width_points() { // A swell in the middle: flat at its widest, and a quarter of the way along above the - // straight ramp from 1 to 3 (2). + // straight ramp from 1 to 3 (2) because the end eases out of the first point. let swell = WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.5, 3.0, 2.0), (1.0, 1.0, 1.0)] }; let (before, after) = slopes(&swell, 0.5); assert!(before.abs() < 1e-4 && after.abs() < 1e-4, "{before} {after}"); let (l, r) = swell.at(0.25); - assert!((l - 2.25).abs() < 1e-12 && (r - 1.625).abs() < 1e-12, "{l} {r}"); + assert!((l - 2.5).abs() < 1e-12 && (r - 1.75).abs() < 1e-12, "{l} {r}"); // Rising on both sides of a point: the slope is the same either side of it. let rise = WidthProfile { points: vec![(0.0, 0.0, 0.0), (0.3, 1.0, 1.0), (1.0, 4.0, 4.0)] }; let (before, after) = slopes(&rise, 0.3); diff --git a/crates/doc/src/appearance/tests.rs b/crates/doc/src/appearance/tests.rs index 0a1b3e1a5..aa2ba0100 100644 --- a/crates/doc/src/appearance/tests.rs +++ b/crates/doc/src/appearance/tests.rs @@ -197,12 +197,85 @@ fn strokes_without_pressure_round_trip_unchanged() { fn profile_interp() { let p = WidthProfile::lens(); assert_eq!(p.at(0.5), (1.0, 1.0)); - // Half way up the lens the width curves above the straight ramp (0.5), flattening into its - // widest point (#5714). - assert_eq!(p.at(0.25), (0.625, 0.625)); + // The lens is a parabola between its ends, so a quarter of the way along is 0.75: above the + // 0.5 a straight taper would give. + assert_eq!(p.at(0.25), (0.75, 0.75)); assert_eq!(p.at(2.0), (0.0, 0.0)); } +/// The width runs smoothly between points: it passes through them, has one slope either side +/// of one, and never overshoots the widths its points set. +#[test] +fn profile_is_smooth_between_points() { + let p = [(0.0, 1.0, 2.0), (0.5, 0.2, 2.0), (1.0, 1.0, 2.0)]; + assert!((WidthProfile::at_points(&p, 0.5).0 - 0.2).abs() < 1e-12); + let slope = |t: f64| (WidthProfile::at_points(&p, t + 1e-6).0 - WidthProfile::at_points(&p, t - 1e-6).0) / 2e-6; + // A local minimum at the middle point is flat there, so the two sides agree. + assert!(slope(0.5).abs() < 1e-3, "{}", slope(0.5)); + for k in 0..=100 { + let w = WidthProfile::at_points(&p, k as f64 / 100.0).0; + assert!((0.2..=1.0).contains(&w), "{w} at {k}"); + } + // The side with no points of its own stays flat. + for k in 0..=100 { + assert!((WidthProfile::at_points(&p, k as f64 / 100.0).1 - 2.0).abs() < 1e-12); + } +} + +/// A run with only two points takes its secant as both end slopes, so it stays a straight +/// taper on each side instead of becoming a smoothstep. +#[test] +fn profile_two_point_runs_are_linear() { + let p = [(0.0, 0.0, 1.0), (1.0, 1.0, 0.0)]; + for k in 0..=100 { + let t = k as f64 / 100.0; + let (l, r) = WidthProfile::at_points(&p, t); + assert!((l - t).abs() < 1e-12, "left {l} at {t}"); + assert!((r - (1.0 - t)).abs() < 1e-12, "right {r} at {t}"); + } +} + +/// The lens's one-sided end slopes make it a parabola: at a quarter of the way it is 0.75, +/// above the 0.5 a straight line between its points would give. +#[test] +fn profile_lens_is_convex() { + let p = WidthProfile::lens(); + assert!((p.at(0.25).0 - 0.75).abs() < 1e-12); + assert!((p.at(0.75).0 - 0.75).abs() < 1e-12); + for k in 1..25 { + let t = k as f64 / 100.0; + assert!(p.at(t).0 > 2.0 * t, "{} at {t}", p.at(t).0); + assert!(p.at(1.0 - t).0 > 2.0 * t, "{} at {}", p.at(1.0 - t).0, 1.0 - t); + } +} + +/// A step still steps, and the smooth runs either side meet it flat. +#[test] +fn profile_steps_stay_steps() { + let p = [(0.0, 1.0, 1.0), (0.5, 1.0, 1.0), (0.5, 2.0, 2.0), (1.0, 2.0, 2.0)]; + assert_eq!(WidthProfile::at_points(&p, 0.5), (1.0, 1.0)); + assert_eq!(WidthProfile::at_points(&p, 0.5 + 1e-6), (2.0, 2.0)); + assert_eq!(WidthProfile::at_points(&p, 0.5 - 1e-6), (1.0, 1.0)); + assert_eq!(WidthProfile::at_points(&p, 0.25), (1.0, 1.0)); + assert_eq!(WidthProfile::at_points(&p, 0.75), (2.0, 2.0)); +} + +/// Moving a point only reshapes the segments beside it (local influence), not the whole path. +#[test] +fn profile_points_only_affect_the_curve_around_them() { + // A monotone run, so no neighbouring slope is held flat by a local extremum. + let a = [(0.0, 0.0, 0.0), (0.25, 0.3, 0.3), (0.5, 0.5, 0.5), (0.75, 0.7, 0.7), (1.0, 1.0, 1.0)]; + let mut b = a; + b[3] = (0.75, 0.9, 0.9); + // Moving p[3] changes the slopes at p[2] and p[3], so p[1] and p[4] also shape the + // curve: nothing before p[1] may move. + for k in 0..=24 { + let t = k as f64 / 100.0; + assert_eq!(WidthProfile::at_points(&a, t), WidthProfile::at_points(&b, t), "at {t}"); + } + assert_ne!(WidthProfile::at_points(&a, 0.3), WidthProfile::at_points(&b, 0.3)); +} + #[test] fn profile_presets_round_trip_their_ids() { for p in WidthProfile::PRESETS { diff --git a/crates/doc/src/reach.rs b/crates/doc/src/reach.rs index f6b9a4d01..8279507e0 100644 --- a/crates/doc/src/reach.rs +++ b/crates/doc/src/reach.rs @@ -80,10 +80,8 @@ fn head_ends(st: &StrokeLayer, bp: &BezPath) -> Vec<(Point, Vec2, bool)> { /// at `t`): the profile at that fraction of the subpath's length, or wider where an end of the /// segment is a corner. The stroke draws the sides there straight to the corner's join and inner /// meeting point, which can lie outside the exact width all along the segment. -fn profile_at(profile: &WidthProfile, segs: &[PathSeg], closed: bool, i: usize, t: f64) -> (f64, f64) { +fn profile_at(profile: &WidthProfile, segs: &[PathSeg], lens: &[f64], total: f64, closed: bool, i: usize, t: f64) -> (f64, f64) { let n = segs.len(); - let lens: Vec = segs.iter().map(|s| s.arclen(ARCLEN_ACCURACY)).collect(); - let total: f64 = lens.iter().sum::().max(1e-12); let before = lens[..i].iter().sum::(); let mut w = profile.at((before + segs[i].subsegment(0.0..t).arclen(ARCLEN_ACCURACY)) / total); let corner = |a: usize, b: usize| (closed || a < b) && kink(&segs[a], &segs[b]); @@ -306,7 +304,8 @@ impl StrokeLayer { /// Does this stroke along `bp` (filled with `rule`) paint within `tol` of `p`? Inside and /// outside strokes of closed paths count on their own side only (and within `tol` of the - /// path); a width profile counts at its width where `p` is nearest. + /// path); a width profile counts at the width of every segment that can reach `p`, as the + /// wider side of a farther one can paint past the nearest. pub fn hits(&self, bp: &BezPath, rule: FillRule, p: Point, tol: f64) -> bool { let reach = self.reach() + tol; if self.width.is_nan() || self.width <= 0.0 || !bp.bounding_box().inflate(reach, reach).contains(p) { @@ -330,17 +329,27 @@ impl StrokeLayer { let segs = drawn(&els[r]); let (half, region) = self.body(closed); let full = half * self.profile_max() * dash_caps.unwrap_or(1.0); - // The nearest point on this subpath. - let near = segs.iter().enumerate().map(|(i, s)| (i, s.nearest(p, 1e-9))).min_by(|a, b| a.1.distance_sq.total_cmp(&b.1.distance_sq)); - if let Some((i, nr)) = near - && nr.distance_sq.sqrt() <= full + tol - { + // Every segment near enough to reach `p`: with a width profile the wider side of a + // farther segment can paint past the nearest one, so the nearest alone is not enough. + let near = segs.iter().enumerate().filter_map(|(i, s)| { + let nr = s.nearest(p, 1e-9); + (nr.distance_sq.sqrt() <= full + tol).then_some((i, nr)) + }); + // The lengths are only needed when a profiled candidate reaches `p`; compute them + // once for the whole subpath rather than once per candidate. + let mut lengths: Option<(Vec, f64)> = None; + for (i, nr) in near { let d = nr.distance_sq.sqrt(); let factor = if dash_caps.is_some() { full / half.max(1e-12) } else { self.profile.as_ref().map_or(1.0, |pr| { - let (l, rt) = profile_at(pr, &segs, closed, i, nr.t); + let (lens, total) = lengths.get_or_insert_with(|| { + let lens: Vec = segs.iter().map(|s| s.arclen(ARCLEN_ACCURACY)).collect(); + let total = lens.iter().sum::().max(1e-12); + (lens, total) + }); + let (l, rt) = profile_at(pr, &segs, lens, *total, closed, i, nr.t); // Nearest to a segment's end, `p` is beside a join or past a cap, which // spans the widths of both sides. let at_end = nr.t <= 1e-9 || nr.t >= 1.0 - 1e-9; diff --git a/crates/effects/src/stroke/arrow.rs b/crates/effects/src/stroke/arrow.rs index c96dcb477..acb980204 100644 --- a/crates/effects/src/stroke/arrow.rs +++ b/crates/effects/src/stroke/arrow.rs @@ -173,6 +173,18 @@ fn bar(hw: f64) -> f64 { /// Where the notch of the (open) arrow sits, as a fraction of the length. const NOTCH: f64 = 0.7; +/// How far [`super::stroke_pieces`] trims the line under each end's head: the head's inset from +/// the tip, along the path. Both are 0 without a head at that end, and in [`ArrowAlign::Extend`], +/// where the stroke keeps its full length. +pub(super) fn trims(st: &StrokeLayer) -> (f64, f64) { + if st.arrow_align != ArrowAlign::Tip { + return (0.0, 0.0); + } + let cap = cap_extent(st.cap, st.width); + let inset_of = |kind: Option, end: bool| kind.map_or(0.0, |k| inset(k, st.arrow_weight(end), cap)); + (inset_of(st.start_arrow, false), inset_of(st.end_arrow, true)) +} + /// Distance from the tip where the stroke ends, for a stroke whose cap reaches `cap` past its end. fn inset(kind: Arrowhead, hw: f64, cap: f64) -> f64 { let l = len(hw); diff --git a/crates/effects/src/stroke/gradient.rs b/crates/effects/src/stroke/gradient.rs index 1cb79e9c7..4bddf80b0 100644 --- a/crates/effects/src/stroke/gradient.rs +++ b/crates/effects/src/stroke/gradient.rs @@ -18,11 +18,11 @@ 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}; -use vectorcraft_doc::{GradientMesh, LineJoin, MeshPoint, StrokeAlign, StrokeGradientMode, StrokeLayer}; +use vectorcraft_doc::{GradientMesh, LineCap, LineJoin, MeshPoint, StrokeAlign, StrokeGradientMode, StrokeLayer}; use vectorcraft_geom::FillRule; use super::width::{flatten, left, length, split_at}; -use super::{aligned_width, cap_extent, is_closed, segments, subpaths, unit}; +use super::{aligned_width, arrow, cap_extent, is_closed, segments, subpaths, unit}; /// Largest stretch of a rib on a turn's bisector (1 / cos of half the turn, about 74°) before the /// corner gets a fan of ribs round its outside. @@ -97,7 +97,10 @@ impl Lay<'_> { /// 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 + // A square cap's corners stand √2 of the half-width out from where the dash ends, and a + // dash can end just past a corner: reach that far, or they paint outside every slice. + let diag = if self.st.dash.is_some() && self.st.cap == LineCap::Square { std::f64::consts::SQRT_2 } else { 1.0 }; + self.half(t) * diag + self.margin } /// The across band at fraction `t` of a subpath whose visible side is `side` (`Some(true)`: @@ -162,6 +165,9 @@ fn strips(bp: &BezPath, rule: FillRule, st: &StrokeLayer, tol: f64, margin: f64, return vec![]; } let heads = (st.head_reach(false), st.head_reach(true)); + // With a tip-aligned arrowhead the stroke's line, and with it the width profile, stops at the + // head's inset from the tip (`stroke_pieces`). + let trims = arrow::trims(st); 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)); @@ -170,27 +176,34 @@ fn strips(bp: &BezPath, rule: FillRule, st: &StrokeLayer, tol: f64, margin: f64, let count = subs.len(); let mut out = vec![]; for (k, (range, closed)) in subs.into_iter().enumerate() { - let (pts, corner) = flatten(&segments(&bp.elements()[range]), closed, tol.max(1e-4)); - let side = - (st.align != StrokeAlign::Center && closed_path && closed).then(|| band_on_left(bp, rule, &pts, st.align, (4.0 * tol).min(hw / 2.0))); + let segs = segments(&bp.elements()[range]); + let flat = flatten(&segs, closed, tol.max(1e-4)); + let side = (st.align != StrokeAlign::Center && closed_path && closed) + .then(|| band_on_left(bp, rule, &flat.pts, st.align, (4.0 * tol).min(hw / 2.0))); // Arrowheads sit on the start of the first subpath and the end of the last one. let heads = (if k == 0 { heads.0 } else { 0.0 }, if k + 1 == count { heads.1 } else { 0.0 }); - let ribs = match pts.len() { + let trims = (if k == 0 { trims.0 } else { 0.0 }, if k + 1 == count { trims.1 } else { 0.0 }); + let ribs = match flat.pts.len() { 0 => continue, // 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)); 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)] + vec![Rib::square(flat.pts[0] - d * e, d, r, 0.0, b), Rib::square(flat.pts[0] + d * e, d, r, 0.0, b)] } _ => { - let total = length(&pts, closed); + let total = length(&flat.pts, closed); if total <= 1e-12 { continue; } let steps = if max_dt < 1.0 { (1.0 / max_dt).ceil() as usize } else { 1 }; let profile = st.profile.iter().flat_map(|p| p.points.iter().map(|q| q.0)); let heads = if closed { (0.0, 0.0) } else { heads }; + // A tip-aligned arrowhead shortens the line it sits on, and the stroke's outline + // reads the width profile along the shortened line (`stroke_pieces`); the ribs + // must read it there too, or on a profile that changes fast they cover a narrower + // band than the stroke paints. + let span = if closed { (0.0, total) } else { (trims.0.min(total), (total - trims.1).max(trims.0)) }; // Where an arrowhead's reach ends gets a rib of its own, so the ribs that reach // across a head run all the way there instead of narrowing towards the next corner // from the head's tip, which leaves the corners of its base outside (#5964). @@ -200,11 +213,11 @@ fn strips(bp: &BezPath, rule: FillRule, st: &StrokeLayer, tol: f64, margin: f64, .copied() .chain(profile) .chain((1..steps).map(|i| i as f64 / steps as f64)) - .map(|t| t * total) + .map(|t| span.0 + (span.1 - span.0) * t) .chain(head_ends) .collect(); - let (pts, _) = split_at(&pts, &corner, closed, &at); - strip(&lay, &pts, closed, total, side, heads) + let (pts, _, _) = split_at(&flat, closed, &at, &segs); + strip(&lay, &pts, closed, total, side, heads, span) } }; out.push(ribs); @@ -226,8 +239,9 @@ fn band_on_left(bp: &BezPath, rule: FillRule, pts: &[Point], align: StrokeAlign, } /// The ribs along one flattened subpath `pts` of length `total` (`heads`: how far arrowheads -/// reach from its start and end). -fn strip(lay: &Lay, pts: &[Point], closed: bool, total: f64, side: Option, heads: (f64, f64)) -> Vec { +/// reach from its start and end). `span` is the stretch of the subpath the width profile is laid +/// along: the whole of it, or what is left between tip-aligned arrowheads. +fn strip(lay: &Lay, pts: &[Point], closed: bool, total: f64, side: Option, heads: (f64, f64), span: (f64, f64)) -> Vec { let n = pts.len(); let seg_count = if closed { n } else { n - 1 }; let dir: Vec = (0..seg_count).map(|i| unit(pts[(i + 1) % n] - pts[i])).collect(); @@ -249,7 +263,9 @@ fn strip(lay: &Lay, pts: &[Point], closed: bool, total: f64, side: Option, // stroke is wide anywhere: where a stroke crosses or runs close by itself, a narrow part // reaching that far would paint over a wide part beside it (#5960). The start of a closed // subpath is also its end. Ribs near an arrowhead reach across it. - let cover: Vec = (0..n).map(|i| if closed && i == 0 { lay.cover(0.0).max(lay.cover(1.0)) } else { lay.cover(along[i] / total) }).collect(); + // The fraction of the profile at how far `s` is along the subpath: the profile spans `span`. + let t_at = |s: f64| if span.1 > span.0 { ((s - span.0) / (span.1 - span.0)).clamp(0.0, 1.0) } else { 0.0 }; + let cover: Vec = (0..n).map(|i| if closed && i == 0 { lay.cover(0.0).max(lay.cover(1.0)) } else { lay.cover(t_at(along[i])) }).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, @@ -279,7 +295,7 @@ fn strip(lay: &Lay, pts: &[Point], closed: bool, total: f64, side: Option, let (first, last) = if closed { (0, n) } else { (1, n - 1) }; for i in first..last { let v = i % n; - let t = along[v] / total; + let t = t_at(along[v]); 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 }; diff --git a/crates/effects/src/stroke/tests.rs b/crates/effects/src/stroke/tests.rs index 46969ff63..fc3f2eee4 100644 --- a/crates/effects/src/stroke/tests.rs +++ b/crates/effects/src/stroke/tests.rs @@ -232,16 +232,14 @@ fn line_outline_uses_the_profile_dashes_or_a_plain_stroke() { let plain = line_outline(&l, &stroke(10.0, |_| {}), 10.0, 1e-3); assert!(near(plain.area().abs(), 1000.0, 1e-6)); let lens = line_outline(&l, &stroke(10.0, |s| s.profile = Some(WidthProfile::lens())), 10.0, 1e-3); - // The lens swells on a cubic, flat at the middle: 7/12 of the full-width band (#5714). - assert!(near(lens.area().abs(), 1000.0 * 7.0 / 12.0, 0.05)); - // Each dash takes its widths from where it sits along the lens (0 → 1 → 0). Half of the lens - // rises as w(u) = u + u² − u³ (u from 0 to 1 across that half): the dash over 0..25 covers - // its first half, ∫₀^½ w = 29/192, the one over 50..75 the wider other half, 83/192. + // The lens is a parabola between its ends: 2/3 of the full-width band. + assert!(near(lens.area().abs(), 2000.0 / 3.0, 0.1)); + // Each dash takes its widths from where it sits along the lens (0 → 1 → 0). let st = stroke(10.0, |s| { s.profile = Some(WidthProfile::lens()); s.dash = Some(dashed(&[25.0, 25.0], 0.0)); }); - assert!(near(line_outline(&l, &st, 10.0, 1e-3).area().abs(), 500.0 * (29.0 + 83.0) / 192.0, 0.05)); + assert!(near(line_outline(&l, &st, 10.0, 1e-3).area().abs(), 1000.0 / 3.0, 0.1)); assert!(line_outline(&BezPath::new(), &st, 10.0, 1e-3).elements().is_empty()); } diff --git a/crates/effects/src/stroke/tests_fit.rs b/crates/effects/src/stroke/tests_fit.rs index b427fdbce..b389cab85 100644 --- a/crates/effects/src/stroke/tests_fit.rs +++ b/crates/effects/src/stroke/tests_fit.rs @@ -155,9 +155,10 @@ fn each_dash_takes_its_width_from_the_profile_along_the_whole_path() { s.dash = Some(Dash { pattern: vec![10.0, 10.0], offset: 0.0, align_corners: false }); }); let o = line_outline(&line(100.0), &st, 10.0, 1e-3); - // The dash over 40..50 is near the lens's widest point; the one over 0..10 near its tip. + // The dash over 40..50 is near the lens's widest point; the one over 0..10 near its tip, + // where the smooth profile is thinner than a straight ramp (a parabola's first tenth). assert!(o.contains(Point::new(45.0, 4.0)) && o.contains(Point::new(45.0, -4.0)), "middle dash is wide"); - assert!(!o.contains(Point::new(5.0, 2.0)) && o.contains(Point::new(5.0, 0.3)), "first dash is thin"); + assert!(o.contains(Point::new(5.0, 0.8)) && !o.contains(Point::new(5.0, 1.2)), "first dash is thin"); assert!(!o.contains(Point::new(15.0, 0.0)), "gaps stay empty"); // Dots too: a round dot near the middle is bigger than one near an end. let st = stroke(10.0, |s| { @@ -166,7 +167,7 @@ fn each_dash_takes_its_width_from_the_profile_along_the_whole_path() { s.dash = Some(Dash { pattern: vec![0.0, 10.0], offset: 0.0, align_corners: false }); }); let o = line_outline(&line(100.0), &st, 10.0, 1e-3); - assert!(o.contains(Point::new(50.0, 4.5)) && !o.contains(Point::new(10.0, 1.5)) && o.contains(Point::new(10.0, 0.5))); + assert!(o.contains(Point::new(50.0, 4.5)) && o.contains(Point::new(10.0, 1.4)) && !o.contains(Point::new(10.0, 2.0))); } /// An L whose corner turns 90° at (100, 0), or a sharp V turning back at (100, 0). diff --git a/crates/effects/src/stroke/tests_gradient.rs b/crates/effects/src/stroke/tests_gradient.rs index a0056d6b3..ea966ea07 100644 --- a/crates/effects/src/stroke/tests_gradient.rs +++ b/crates/effects/src/stroke/tests_gradient.rs @@ -138,9 +138,8 @@ fn across_runs_from_the_left_edge_to_the_right() { let mut lens = st.clone(); lens.profile = Some(WidthProfile::lens()); let sl = slices(&line, &lens); - // A quarter of the way along, the lens is 0.625 as wide (its curve, #5714): its top edge is - // 3.125 up. - assert!(param(&sl, Point::new(25.0, -3.0)).unwrap() < 0.05); + // A quarter of the way along, the lens is three quarters as wide: its top edge is 3.75 up. + assert!(param(&sl, Point::new(25.0, -3.6)).unwrap() < 0.05); assert!((param(&sl, Point::new(50.0, -2.5)).unwrap() - 0.25).abs() < 0.02); } diff --git a/crates/effects/src/stroke/width.rs b/crates/effects/src/stroke/width.rs index 4a9dc2678..0b307cc6b 100644 --- a/crates/effects/src/stroke/width.rs +++ b/crates/effects/src/stroke/width.rs @@ -3,17 +3,18 @@ //! A stroke with a [`WidthProfile`] is drawn as a filled outline: each subpath is flattened, and //! at every sample the left and right offsets `width/2 · factor(t)` (t = fraction of the //! subpath's length) are placed along the normal. Corners of the path take the stroke's miter, -//! round or bevel join on their outer side (smooth points bend round), open subpaths get butt, -//! round or projecting caps (the round cap blends the two side widths), and closed subpaths become -//! two loops of opposite orientation filled with the non-zero rule. Dashes are pieces of a longer -//! subpath and read the profile where they sit along it ([`outline_spans`]). +//! round or bevel join on their outer side (smooth points offset along the curve's normal), open +//! subpaths get butt, round or projecting caps (the round cap blends the two side widths), and +//! closed subpaths become two loops of opposite orientation filled with the non-zero rule. Dashes +//! are pieces of a longer subpath and read the profile where they sit along it ([`outline_spans`]). use std::f64::consts::PI; -use kurbo::{BezPath, ParamCurve, PathEl, PathSeg, Point, Rect, Vec2}; +use kurbo::{BezPath, ParamCurve, ParamCurveNearest, PathEl, PathSeg, Point, Rect, Vec2}; use vectorcraft_doc::{LineCap, LineJoin, StrokeLayer, WidthProfile}; +use vectorcraft_pathops::fit_cubics_through; -use super::{kink, segments, subpaths, unit}; +use super::{kink, segments, subpaths, tangent, unit}; /// Filled outline of `bp` stroked with `width` scaled by `profile`, with the caps, joins and miter /// limit of `st`, flattened to `tol`. @@ -24,15 +25,16 @@ pub fn width_outline(bp: &BezPath, width: f64, profile: &WidthProfile, st: &Stro /// Outline only flattened segments near `near`, retaining each run's original fractions of its /// whole subpath for the width profile. `None` means no segments could be omitted. pub(super) fn width_outline_near(bp: &BezPath, width: f64, profile: &WidthProfile, st: &StrokeLayer, tol: f64, near: Rect) -> Option { - let ctx = Ctx { half: width / 2.0, profile, cap: st.cap, join: st.join, miter_limit: st.miter_limit, tol: tol.max(1e-4) }; + let ctx = Ctx::new(width, profile, st, tol); let mut out = BezPath::new(); let mut cut = false; for (r, closed) in subpaths(bp) { if closed { return None; } - let (pts, corner) = flatten(&segments(&bp.elements()[r]), closed, ctx.tol); - let n = pts.len(); + let segs = segments(&bp.elements()[r]); + let flat = flatten(&segs, closed, ctx.tol); + let n = flat.pts.len(); if n < 2 { continue; } @@ -41,7 +43,7 @@ pub(super) fn width_outline_near(bp: &BezPath, width: f64, profile: &WidthProfil let mut kept = Vec::with_capacity(count); let mut total = 0.0; for i in 0..count { - let (a, b) = (pts[i], pts[i + 1]); + let (a, b) = (flat.pts[i], flat.pts[i + 1]); let len = a.distance(b); if !len.is_finite() { return None; @@ -51,7 +53,7 @@ pub(super) fn width_outline_near(bp: &BezPath, width: f64, profile: &WidthProfil kept.push(!(a.x.max(b.x) < near.x0 || a.x.min(b.x) > near.x1 || a.y.max(b.y) < near.y0 || a.y.min(b.y) > near.y1)); } if kept.iter().all(|v| *v) { - ctx.subpath(&mut out, &pts, &corner, closed, (0.0, 1.0)); + ctx.subpath(&mut out, &flat, &segs, closed, (0.0, 1.0), count as f64); continue; } cut = true; @@ -59,27 +61,31 @@ pub(super) fn width_outline_near(bp: &BezPath, width: f64, profile: &WidthProfil continue; } let mut walked = 0.0; - let mut run = Vec::new(); - let mut run_corner = Vec::new(); + let mut run: Option = None; let mut from = 0.0; for i in 0..count { if kept[i] { - if run.is_empty() { + if run.is_none() { from = walked / total; - run.push(pts[i]); - run_corner.push(corner[i]); + run = Some(Flat { pts: vec![flat.pts[i]], corner: vec![flat.corner[i]], seg_of: vec![], param: vec![] }); } - run.push(pts[i + 1]); - run_corner.push(corner[i + 1]); - } else if run.len() > 1 { - ctx.subpath(&mut out, &run, &run_corner, false, (from, walked / total)); - run.clear(); - run_corner.clear(); + if let Some(part) = run.as_mut() { + part.pts.push(flat.pts[i + 1]); + part.corner.push(flat.corner[i + 1]); + part.seg_of.push(flat.seg_of[i]); + part.param.push(flat.param[i]); + } + } else if let Some(part) = run.take() + && part.pts.len() > 1 + { + ctx.subpath(&mut out, &part, &segs, false, (from, walked / total), count as f64); } walked += lengths[i]; } - if run.len() > 1 { - ctx.subpath(&mut out, &run, &run_corner, false, (from, walked / total)); + if let Some(part) = run + && part.pts.len() > 1 + { + ctx.subpath(&mut out, &part, &segs, false, (from, walked / total), count as f64); } } cut.then_some(out) @@ -89,12 +95,15 @@ pub(super) fn width_outline_near(bp: &BezPath, width: f64, profile: &WidthProfil /// `spans[i]` of the subpath it was cut from (see [`Dashed::spans`](super::Dashed::spans)); /// subpaths without a span are whole. pub(super) fn outline_spans(bp: &BezPath, spans: &[(f64, f64)], width: f64, profile: &WidthProfile, st: &StrokeLayer, tol: f64) -> BezPath { - let ctx = Ctx { half: width / 2.0, profile, cap: st.cap, join: st.join, miter_limit: st.miter_limit, tol: tol.max(1e-4) }; + let ctx = Ctx::new(width, profile, st, tol); let mut out = BezPath::new(); for (i, (r, closed)) in subpaths(bp).into_iter().enumerate() { - let (pts, corner) = flatten(&segments(&bp.elements()[r]), closed, ctx.tol); - if pts.len() > 1 { - ctx.subpath(&mut out, &pts, &corner, closed, spans.get(i).copied().unwrap_or((0.0, 1.0))); + let segs = segments(&bp.elements()[r]); + let flat = flatten(&segs, closed, ctx.tol); + if flat.pts.len() > 1 { + let span = spans.get(i).copied().unwrap_or((0.0, 1.0)); + let pieces = if closed { flat.pts.len() } else { flat.pts.len() - 1 }.max(1); + ctx.subpath(&mut out, &flat, &segs, closed, span, pieces as f64 / (span.1 - span.0)); } } out @@ -105,19 +114,34 @@ pub(super) fn left(t: Vec2) -> Vec2 { Vec2::new(t.y, -t.x) } +/// A flattened subpath: its points, which of them are corners, and the source segment and +/// parameter range that drew each flattened piece (so a sample added between two points can read +/// that piece's tangent instead of the chord's direction). +pub(super) struct Flat { + pub(super) pts: Vec, + pub(super) corner: Vec, + pub(super) seg_of: Vec, + /// The source segment's parameter range for each flattened piece. Restricting the tangent + /// search to this range keeps a sample on the branch that drew it when a cubic loops back + /// near itself. + pub(super) param: Vec<(f64, f64)>, +} + /// The flattened points of one subpath (consecutive duplicates removed; a closed one doesn't /// repeat its start), each marked when it is a corner of the path. -pub(super) fn flatten(segs: &[PathSeg], closed: bool, tol: f64) -> (Vec, Vec) { - let Some(s0) = segs.first() else { return (vec![], vec![]) }; - let (mut pts, mut corner) = (vec![s0.start()], vec![false]); - // The first and the last segment that drew something. - let (mut first, mut prev): (Option<&PathSeg>, Option<&PathSeg>) = (None, None); - for seg in segs { - let at = pts.len() - 1; +pub(super) fn flatten(segs: &[PathSeg], closed: bool, tol: f64) -> Flat { + let Some(s0) = segs.first() else { return Flat { pts: vec![], corner: vec![], seg_of: vec![], param: vec![] } }; + let mut flat = Flat { pts: vec![s0.start()], corner: vec![false], seg_of: vec![], param: vec![] }; + // The last segment that drew something, and the last parameter it reached. + let mut prev: Option<&PathSeg> = None; + let mut last_added: Option<(usize, f64)> = None; + for (si, seg) in segs.iter().enumerate() { + let at = flat.pts.len() - 1; + let before = flat.pts.len(); let mut push = |p: Point| { - if pts.last().is_none_or(|l| l.distance(p) > 1e-9) { - pts.push(p); - corner.push(false); + if flat.pts.last().is_none_or(|l| l.distance(p) > 1e-9) { + flat.pts.push(p); + flat.corner.push(false); } }; match seg { @@ -130,77 +154,151 @@ pub(super) fn flatten(segs: &[PathSeg], closed: bool, tol: f64) -> (Vec, } }), } - if pts.len() > at + 1 { - match prev { - Some(p) => corner[at] = kink(p, seg), - None => first = Some(seg), + if flat.pts.len() > before { + let end = flat.pts.len() - 1; + flat.seg_of.resize(end, si); + for slot in flat.seg_of.iter_mut().take(end).skip(at) { + *slot = si; + } + // Find each new point's source parameter, never going backwards. A point is on the + // curve, so its nearest parameter is exact except at a self-intersection, where the + // monotone search picks the branch that continues this flattened piece. + let mut ts = Vec::with_capacity(end - at + 1); + ts.push(0.0); + let mut t = 0.0; + for p in &flat.pts[at + 1..=end] { + if t >= 1.0 - 1e-12 { + t = 1.0; + } else { + let sub = seg.subsegment(t..1.0); + let nr = sub.nearest(*p, 1e-9); + t = (t + (1.0 - t) * nr.t).clamp(0.0, 1.0); + } + ts.push(t); + } + flat.param.extend(ts.windows(2).map(|w| (w[0], w[1]))); + last_added = ts.last().map(|t| (si, *t)); + if let Some(p) = prev { + flat.corner[at] = kink(p, seg); } prev = Some(seg); } } if closed { - if pts.len() > 1 && pts[0].distance(pts[pts.len() - 1]) <= 1e-9 { - pts.pop(); - corner.pop(); + if flat.pts.len() > 1 && flat.pts[0].distance(flat.pts[flat.pts.len() - 1]) <= 1e-9 { + flat.pts.pop(); + flat.corner.pop(); } - if let (Some(a), Some(b)) = (prev, first) { - corner[0] = kink(a, b); + if let (Some(a), Some(b)) = (segs.last(), segs.first()) { + flat.corner[0] = kink(a, b); + } + // The closing piece comes from the last source segment even when its endpoint is the + // already-present start point and did not add a flattened point. Keep a tangent (and its + // whole parameter range) for it. + flat.seg_of.truncate(flat.pts.len()); + flat.param.truncate(flat.pts.len()); + if flat.seg_of.len() < flat.pts.len() { + let last = segs.len().saturating_sub(1); + // A closing curve with interior flattened points has already reached a parameter + // before its end (the duplicated start point was not added); the missing piece starts + // where it left off, not at 0. + let start = match last_added { + Some((si, t)) if si == last => t, + _ => 0.0, + }; + flat.seg_of.resize(flat.pts.len(), last); + flat.param.resize(flat.pts.len(), (start, 1.0)); } } - (pts, corner) + flat } -/// The most samples added between two width points. -const MAX_SPAN_SAMPLES: usize = 64; -/// The most samples [`densify`] adds between width points along one piece of a subpath. -const MAX_CURVE_SAMPLES: usize = 4096; - -/// Insert samples where the profile has width points (the two points of a discontinuous point -/// share one sample), and between them where the profile's curve ([`WidthProfile::at`]) bends, so -/// that a straight run follows it to within `tol` with a stroke of half width `half`. `span` maps -/// the subpath onto the profile (see [`outline_spans`]). -fn densify(pts: &[Point], corner: &[bool], closed: bool, profile: &WidthProfile, span: (f64, f64), half: f64, tol: f64) -> (Vec, Vec) { - let total = length(pts, closed); +/// Insert samples where the profile has width points and between them, so its smooth curve is +/// drawn as a smooth outline along straight runs too (the two points of a discontinuous point +/// share one sample). `span` maps the subpath onto the profile (see [`outline_spans`]). +fn densify( + flat: &Flat, + segs: &[PathSeg], + closed: bool, + profile: &WidthProfile, + span: (f64, f64), + density: f64, +) -> (Vec, Vec, Vec) { + // Two samples per profile segment: the segment end plus its midpoint. The outline is fitted + // with cubics through these points, so a smooth width curve needs far fewer samples than it + // did when every pair of samples became a separate cubic. A profile with a strong bend gets + // one more subdivision; a flat profile draws the same straight pieces as before. + const SAMPLES: usize = 2; + const MAX_SAMPLES: usize = 8; + // The gap (in width factor) the straight line between two samples may leave: small enough + // that a sharp peak like the lens' needs one more piece, large enough that a long taper's + // gentle bend does not. + const BEND: f64 = 0.05; + let total = length(&flat.pts, closed); let range = span.1 - span.0; if total <= 1e-12 || range <= 1e-12 { - return (pts.to_vec(), corner.to_vec()); + return split_at(flat, closed, &[], segs); } - let along = |t: f64| (t - span.0) / range * total; // Where the profile's points fall along this piece (a span past 1 wraps round a closed path). - let mut marks: Vec = profile.points.iter().flat_map(|p| [p.0, p.0 + 1.0]).map(along).collect(); - let mut added = 0; - for shift in [0.0, 1.0] { - for w in profile.points.windows(2) { - let (a, b) = (&w[0], &w[1]); - let (t0, t1) = (a.0 + shift, b.0 + shift); - if t1 - t0 <= 1e-7 || t1 <= span.0 || t0 >= span.1 { - continue; - } - // A span whose curve is the straight line between its points (a two-point taper, - // points on one line) needs nothing between them. A cubic off the line is off it at a - // quarter or three quarters of the way. - let off_line = |u: f64| { - let (l, r) = profile.at(a.0 + (b.0 - a.0) * u); - half * (l - (a.1 + (b.1 - a.1) * u)).abs().max((r - (a.2 + (b.2 - a.2) * u)).abs()) - }; - if off_line(0.25) <= tol * 1e-3 && off_line(0.75) <= tol * 1e-3 { - continue; - } - // A monotone cubic span bends by at most 12 times its change in width over the span - // squared, so the chords of `n` equal parts stray at most 1.5 · change / n² from it. - let change = half * (b.1 - a.1).abs().max((b.2 - a.2).abs()); - let n = (1.5 * change / tol).sqrt().ceil(); - let n = if n.is_finite() { n.clamp(1.0, MAX_SPAN_SAMPLES as f64) as usize } else { MAX_SPAN_SAMPLES }; - for k in 1..n { - if added >= MAX_CURVE_SAMPLES { - break; - } - marks.push(along(t0 + (t1 - t0) * k as f64 / n as f64)); - added += 1; - } + let mut ts: Vec = profile.points.iter().flat_map(|p| [p.0, p.0 + 1.0]).collect(); + ts.sort_by(f64::total_cmp); + ts.dedup_by(|a, b| (*a - *b).abs() <= 1e-9); + // A flat profile draws the same straight pieces as before: sample only its points. + let constant = profile.points.first().is_some_and(|f| profile.points.iter().all(|p| (p.1 - f.1).abs() <= 1e-12 && (p.2 - f.2).abs() <= 1e-12)); + let cap = (4096 / ts.len().max(1)).max(1); + // A curved path's flattened points already sample the width along the subpath. On a dense + // piece the profile's own points are enough; a straight run has only its ends, so it needs + // the extra samples. This keeps curved, profiled art close to a uniform stroke's size. + let mut marks = Vec::with_capacity(ts.len() * SAMPLES); + for w in ts.windows(2) { + let (a, b) = (w[0], w[1]); + let samples = if constant { + 1 + } else { + // `ts` has the profile both at t and t + 1 for a dash crossing a closed seam; the + // shifted copy has the same shape, so measure its bend on the unshifted segment. + let start = a.rem_euclid(1.0); + let end = start + (b - a); + let want = adaptive_samples(profile, start, end, SAMPLES, MAX_SAMPLES, BEND).min(cap); + // How many of the flattened path's points already fall in this profile segment. + // `density` is measured on the whole subpath, not on this piece, so outlining only + // the piece near the view picks the same samples as outlining the whole subpath. + let existing = (b - a) * density; + if existing >= want as f64 { 1 } else { want } + }; + for k in 0..samples { + marks.push(a + (b - a) * k as f64 / samples as f64); } } - split_at(pts, corner, closed, &marks) + if let Some(&last) = ts.last() { + marks.push(last); + } + let marks: Vec = marks.into_iter().map(|t| (t - span.0) / range * total).collect(); + split_at(flat, closed, &marks, segs) +} + +/// How many pieces one profile segment needs so a cubic fit through their ends keeps the width +/// curve. A straight taper is already straight; a visible bend gets progressively more samples. +fn adaptive_samples(profile: &WidthProfile, t0: f64, t1: f64, base: usize, max: usize, bend: f64) -> usize { + let mut samples = base.max(1); + while samples < max { + // One probe in the middle of each piece: how far the curve leaves the straight line + // between the piece's ends. The more pieces, the closer the line follows the curve, so + // the loop only stops when the pieces it has are fine enough (or at `max`). + let mut dev = 0.0_f64; + for k in 0..samples { + let (a, b) = (k as f64 / samples as f64, (k + 1) as f64 / samples as f64); + let (la, ra) = profile.at(t0 + (t1 - t0) * a); + let (lb, rb) = profile.at(t0 + (t1 - t0) * b); + let (lm, rm) = profile.at(t0 + (t1 - t0) * (a + b) / 2.0); + dev = dev.max((lm - (la + lb) / 2.0).abs()).max((rm - (ra + rb) / 2.0).abs()); + } + if dev <= bend { + break; + } + samples *= 2; + } + samples } /// Length of a flattened subpath (see [`flatten`]). @@ -212,31 +310,70 @@ pub(super) fn length(pts: &[Point], closed: bool) -> f64 { /// A flattened subpath (see [`flatten`]) with points added `marks` along it (distances from its /// start; those on a point or off the subpath are ignored). -pub(super) fn split_at(pts: &[Point], corner: &[bool], closed: bool, marks: &[f64]) -> (Vec, Vec) { +pub(super) fn split_at(flat: &Flat, closed: bool, marks: &[f64], segs: &[PathSeg]) -> (Vec, Vec, Vec) { + let Flat { pts, corner, seg_of, param } = flat; let n = pts.len(); let seg_count = if closed { n } else { n.saturating_sub(1) }; - let mut out = (Vec::with_capacity(n + marks.len()), Vec::with_capacity(n + marks.len())); + // The source's direction at a source parameter. The chord direction is the secant, not the + // tangent: using it for points added inside a chord jogs the normal at every flatten vertex. + let source_at = |si: usize, t: f64| -> Vec2 { segs.get(si).map_or(Vec2::new(1.0, 0.0), |seg| tangent(seg, t)) }; + // A point added inside a flattened piece searches only that piece's source parameter range: + // a looping cubic can pass near itself, and a full-segment nearest would take the other + // branch's tangent. + let source_near = |p: Point, i: usize| -> Vec2 { + let Some(si) = seg_of.get(i).copied() else { return Vec2::new(1.0, 0.0) }; + let Some(seg) = segs.get(si) else { return Vec2::new(1.0, 0.0) }; + let (t0, t1) = param.get(i).copied().unwrap_or((0.0, 1.0)); + if t1 - t0 > 1e-12 { + let sub = seg.subsegment(t0..t1); + tangent(seg, t0 + (t1 - t0) * sub.nearest(p, 1e-9).t) + } else { + tangent(seg, t0) + } + }; + // At a shared point, average the arriving and leaving directions unless it is a corner. + let dir_at = |i: usize| -> Vec2 { + let incoming = if i == 0 { + if closed { seg_of.last().copied().zip(param.last().copied()) } else { None } + } else { + seg_of.get(i - 1).copied().zip(param.get(i - 1).copied()) + }; + let outgoing = seg_of.get(i).copied().zip(param.get(i).copied()); + let a = incoming.map(|(si, (_, t))| source_at(si, t)); + let b = outgoing.map(|(si, (t, _))| source_at(si, t)); + match (a, b) { + (Some(a), Some(b)) if !corner[i] => { + let sum = a + b; + if sum.hypot() > 1e-9 { unit(sum) } else { b } + } + (_, Some(b)) => b, + (Some(a), None) => a, + (None, None) => Vec2::new(1.0, 0.0), + } + }; + let mut out = (Vec::with_capacity(n + marks.len()), Vec::with_capacity(n + marks.len()), Vec::with_capacity(n + marks.len())); let mut acc = 0.0; - // Sorted once, so each segment finds its marks by search, not by a pass over all of them. - let mut marks = marks.to_vec(); - marks.sort_by(f64::total_cmp); for i in 0..seg_count { let (a, b) = (pts[i], pts[(i + 1) % n]); let len = a.distance(b); out.0.push(a); out.1.push(corner[i]); - let from = marks.partition_point(|d| *d <= acc + 1e-9); - let mut inner: Vec = marks.iter().skip(from).copied().take_while(|d| *d < acc + len - 1e-9).collect(); + out.2.push(dir_at(i)); + let mut inner: Vec = marks.iter().copied().filter(|d| *d > acc + 1e-9 && *d < acc + len - 1e-9).collect(); + inner.sort_by(f64::total_cmp); inner.dedup_by(|a, b| (*a - *b).abs() <= 1e-9); for d in inner { - out.0.push(a.lerp(b, (d - acc) / len)); + let p = a.lerp(b, (d - acc) / len); + out.0.push(p); out.1.push(false); + out.2.push(source_near(p, i)); } acc += len; } if !closed && n > 0 { out.0.push(pts[n - 1]); out.1.push(corner[n - 1]); + out.2.push(dir_at(n - 1)); } out } @@ -249,6 +386,8 @@ struct Ctx<'a> { join: LineJoin, miter_limit: f64, tol: f64, + /// The profile has one (left, right) width everywhere. + uniform: bool, } /// A vertex of a flattened subpath: where it is, the directions in and out, the lengths of the @@ -257,14 +396,31 @@ struct Vertex { p: Point, a: Vec2, b: Vec2, + /// The smooth tangent: the source curve's direction at this sample, or the average of its + /// arriving and leaving directions at a smooth join. A smooth point offsets along this + /// normal, not the chord bisector; otherwise the chord normal inside each flattened piece and + /// the bisector at each flatten vertex alternate and the edge is scalloped. + n: Vec2, la: f64, lb: f64, corner: bool, } -impl Ctx<'_> { - fn subpath(&self, out: &mut BezPath, pts: &[Point], corner: &[bool], closed: bool, span: (f64, f64)) { - let (pts, corner) = densify(pts, corner, closed, self.profile, span, self.half, self.tol); +impl<'a> Ctx<'a> { + fn new(width: f64, profile: &'a WidthProfile, st: &StrokeLayer, tol: f64) -> Self { + Self { + half: width / 2.0, + profile, + cap: st.cap, + join: st.join, + miter_limit: st.miter_limit, + tol: tol.max(1e-4), + uniform: profile.points.first().is_some_and(|f| profile.points.iter().all(|p| p.1 == f.1 && p.2 == f.2)), + } + } + + fn subpath(&self, out: &mut BezPath, flat: &Flat, segs: &[PathSeg], closed: bool, span: (f64, f64), density: f64) { + let (pts, corner, dirs) = densify(flat, segs, closed, self.profile, span, density); let n = pts.len(); let seg_count = if closed { n } else { n - 1 }; let seg_dir: Vec = (0..seg_count).map(|i| unit(pts[(i + 1) % n] - pts[i])).collect(); @@ -280,117 +436,398 @@ impl Ctx<'_> { // at a discontinuous point). let width_at = |d: f64| { let t = span.0 + (span.1 - span.0) * d / total; - let (b, a) = self.profile.around(if t > 1.0 { t - 1.0 } else { t }); + // A closed path joins its own start and end: a profile with different widths there + // is a step, not a stretch across the seam. + let (b, a) = if closed && span == (0.0, 1.0) && d <= 1e-12 { + (self.profile.around(1.0).0, self.profile.around(0.0).1) + } else { + self.profile.around(if t > 1.0 { t - 1.0 } else { t }) + }; let side = |(l, r): (f64, f64)| (self.half * l.max(0.0), self.half * r.max(0.0)); (side(b), side(a)) }; - let mut lpts = Vec::with_capacity(n); - let mut rpts = Vec::with_capacity(n); + let mut lpts: Vec<(Point, bool)> = Vec::with_capacity(n); + let mut rpts: Vec<(Point, bool)> = Vec::with_capacity(n); + // Only a butt cap makes the side end at a corner; square and round caps stay tangent to it. + let end_sharp = matches!(self.cap, LineCap::Butt); for (i, &p) in pts.iter().enumerate() { - let (before, after) = width_at(cum[i]); + let ((wl_b, wr_b), (wl_a, wr_a)) = width_at(cum[i]); if !closed && (i == 0 || i == n - 1) { - let nm = left(seg_dir[i.min(seg_count - 1)]); - let (wl, wr) = if i == 0 { after } else { before }; - lpts.push(p + nm * wl); - rpts.push(p - nm * wr); + let nm = left(dirs[i]); + let (wl, wr) = if i == 0 { (wl_a, wr_a) } else { (wl_b, wr_b) }; + lpts.push((p + nm * wl, end_sharp)); + rpts.push((p - nm * wr, end_sharp)); continue; } let ip = if i == 0 { seg_count - 1 } else { i - 1 }; - let v = Vertex { p, a: seg_dir[ip], b: seg_dir[i], la: cum[ip + 1] - cum[ip], lb: cum[i + 1] - cum[i], corner: corner[i] }; + let v = Vertex { p, a: seg_dir[ip], b: seg_dir[i], n: dirs[i], la: cum[ip + 1] - cum[ip], lb: cum[i + 1] - cum[i], corner: corner[i] }; // A step: the side goes straight out (or in) from the width before to the one after. - for (k, (wl, wr)) in [before, after].into_iter().enumerate() { - if k == 0 || (wl, wr) != before { - self.join(&mut lpts, &v, wl, 1.0); - self.join(&mut rpts, &v, wr, -1.0); - } + // Both ends of that straight piece are corners: the run before it must not round + // into the step. + let (step_l, step_r) = (wl_a != wl_b, wr_a != wr_b); + self.join(&mut lpts, &v, wl_b, 1.0, step_l); + self.join(&mut rpts, &v, wr_b, -1.0, step_r); + if step_l { + self.join(&mut lpts, &v, wl_a, 1.0, true); + } + if step_r { + self.join(&mut rpts, &v, wr_a, -1.0, true); } } if closed { - polygon(out, lpts.into_iter()); - polygon(out, rpts.into_iter().rev()); + polygon(out, lpts.into_iter(), self.tol); + polygon(out, rpts.into_iter().rev(), self.tol); return; } - let (t0, t1) = (seg_dir[0], seg_dir[seg_count - 1]); + let (t0, t1) = (dirs[0], dirs[n - 1]); let ((wl0, wr0), (wl1, wr1)) = (width_at(0.0).1, width_at(total).0); let mut ring = lpts; // End cap: from the left side around the end to the right side. - cap_points(&mut ring, pts[n - 1], left(t1), t1, wl1, wr1, self.cap); + cap_points(&mut ring, pts[n - 1], left(t1), t1, wl1, wr1, self.cap, self.tol); ring.extend(rpts.into_iter().rev()); // Start cap: from the right side around the start back to the left side. - cap_points(&mut ring, pts[0], -left(t0), -t0, wr0, wl0, self.cap); - polygon(out, ring.into_iter()); + cap_points(&mut ring, pts[0], -left(t0), -t0, wr0, wl0, self.cap, self.tol); + polygon(out, ring.into_iter(), self.tol); } - /// The offset points at `v` on one side (`side` 1: left, −1: right), `w` from the path: the - /// join on the outer side of a turn, the inner miter point (or a detour through the vertex - /// when that would overshoot a segment) on the inner side. - fn join(&self, pts: &mut Vec, v: &Vertex, w: f64, side: f64) { + /// The offset points at `v` on one side (`side` 1: left, −1: right), `w` from the path. A + /// smooth point follows the vertex normal, the offset of the curve itself; a corner takes the + /// join on its outer side and the inner miter point (or a detour through the vertex when that + /// would overshoot a segment) on its inner side. + /// + /// Each point says whether the outline is sharp there: a path corner, a miter tip, a width + /// step or a butt cap end. Only those break the smooth fitting; a rapid width change makes + /// the offset turn sharply, but it is not a corner and must not be fitted as one. + fn join(&self, pts: &mut Vec<(Point, bool)>, v: &Vertex, w: f64, side: f64, step: bool) { let (na, nb) = (left(v.a) * side, left(v.b) * side); let mid = na + nb; // Cosine of half the turn; the miter point is `w / c` out along `mid`. let c = mid.hypot() / 2.0; - let miter = |pts: &mut Vec| pts.push(v.p + mid * (w / (2.0 * c * c))); if w <= 0.0 { - pts.push(v.p); - } else if v.b.dot(na) <= 0.0 { + pts.push((v.p, true)); + return; + } + let miter = if c > 1e-9 { Some(v.p + mid * (w / (2.0 * c * c))) } else { None }; + // The offset of the smooth path itself is at `w` along the path normal. The polyline + // miter (`w / c`) is the offset of the flattened segments, not of the curve: at a tight + // curve it spiked every sample outward and left a scalloped edge. The source curve's + // tangent is the smooth normal; a corner still uses its two chords for the join. + let smooth = v.p + left(v.n) * (w * side); + if v.b.dot(na) <= 0.0 { // Outer side: the path turns away from it. - let excess = if c > 1e-9 { w * (1.0 / c - 1.0) } else { f64::INFINITY }; - match self.join { - _ if excess <= self.tol => miter(pts), - LineJoin::Miter if v.corner && 1.0 / c <= self.miter_limit => miter(pts), - LineJoin::Miter | LineJoin::Bevel if v.corner => pts.extend([v.p + na * w, v.p + nb * w]), - _ => self.arc(pts, v, na, nb, w), + if !v.corner { + // A smooth point has no join: its offset is along the path normal. + pts.push((smooth, step)); + } else { + let excess = if c > 1e-9 { w * (1.0 / c - 1.0) } else { f64::INFINITY }; + match self.join { + _ if excess <= self.tol => pts.push((miter.unwrap_or(v.p), true)), + LineJoin::Miter if c > 1e-9 && 1.0 / c <= self.miter_limit => pts.push((miter.unwrap_or(v.p), true)), + LineJoin::Miter | LineJoin::Bevel => pts.extend([(v.p + na * w, true), (v.p + nb * w, true)]), + _ => self.arc(pts, v, na, nb, w, step), + } } - } else if c > 1e-9 && w * (1.0 - c * c).sqrt() / c <= v.la.min(v.lb) { - miter(pts); + } else if c <= 1e-9 { + // A reversal has no inner miter; go through the vertex. That is a notch in the + // outline, so it stays sharp: fitting a curve through it would loop. + pts.extend([(v.p + na * w, true), (v.p, true), (v.p + nb * w, true)]); + } else if !v.corner { + // A smooth point's inner offset follows its normal too. With a constant profile, once + // the half-width reaches the local radius of curvature the offset crosses the centre of + // curvature and comes back as a reversed contour, which the non-zero fill rules into a + // hole. The stroke covers that centre, so cut through it instead: the cut is inside the + // painted region and does not show. (With varying widths the crossing usually marks a + // real caustic; a cut there would show as a slit, so leave it to the offset.) + let turn = 2.0 * c.clamp(-1.0, 1.0).acos(); + let radius = if turn > 1e-9 { 0.5 * (v.la + v.lb) / turn } else { f64::INFINITY }; + if self.uniform && w >= radius { + pts.push((smooth, true)); + pts.push((v.p, true)); + pts.push((smooth, true)); + } else { + pts.push((smooth, step)); + } + } else if w * (1.0 - c * c).sqrt() / c <= v.la.min(v.lb) { + // A corner's inner offset is its miter while that stays inside both segments. + pts.push((miter.unwrap_or(v.p), true)); } else { - pts.extend([v.p + na * w, v.p, v.p + nb * w]); + // The inner offset of a corner cuts through the vertex; keep that notch sharp. + pts.extend([(v.p + na * w, true), (v.p, true), (v.p + nb * w, true)]); } } /// A round join from `na` to `nb` (unit normals) at radius `w` around `v`; a full reversal - /// rounds through the direction of travel. - fn arc(&self, pts: &mut Vec, v: &Vertex, na: Vec2, nb: Vec2, w: f64) { + /// rounds through the direction of travel. `sharp` marks a step at the join's ends. + fn arc(&self, pts: &mut Vec<(Point, bool)>, v: &Vertex, na: Vec2, nb: Vec2, w: f64, sharp: bool) { let phi = if (na + nb).hypot() < 1e-9 { if Vec2::new(-na.y, na.x).dot(v.a) > 0.0 { PI } else { -PI } } else { na.cross(nb).atan2(na.dot(nb)) }; let step = 2.0 * (1.0 - (self.tol / w).min(1.0)).acos(); let steps = ((phi.abs() / step.max(1e-3)).ceil() as usize).clamp(1, 256); for k in 0..=steps { let (s, c) = (phi * k as f64 / steps as f64).sin_cos(); - pts.push(v.p + Vec2::new(na.x * c - na.y * s, na.x * s + na.y * c) * w); + pts.push((v.p + Vec2::new(na.x * c - na.y * s, na.x * s + na.y * c) * w, sharp)); } } } /// Cap points between `c + u·r0` and `c − u·r1`, bulging towards `v`. -fn cap_points(ring: &mut Vec, c: Point, u: Vec2, v: Vec2, r0: f64, r1: f64, cap: LineCap) { +#[allow(clippy::too_many_arguments)] +fn cap_points(ring: &mut Vec<(Point, bool)>, c: Point, u: Vec2, v: Vec2, r0: f64, r1: f64, cap: LineCap, tol: f64) { match cap { LineCap::Butt => {} LineCap::Square => { let h = (r0 + r1) / 2.0; - ring.push(c + u * r0 + v * h); - ring.push(c - u * r1 + v * h); + ring.push((c + u * r0 + v * h, true)); + ring.push((c - u * r1 + v * h, true)); } LineCap::Round => { - let steps = 16; + // Enough steps that the cap's facets stay within `tol`, as a round join does. + let r = r0.max(r1).max(1e-9); + let step = 2.0 * (1.0 - (tol / r).min(1.0)).acos(); + let steps = ((PI / step.max(1e-6)).ceil() as usize).clamp(8, 256); for k in 1..steps { - let a = std::f64::consts::PI * k as f64 / steps as f64; + let a = PI * k as f64 / steps as f64; let r = r0 + (r1 - r0) * (k as f64 / steps as f64); - ring.push(c + u * (a.cos() * r) + v * (a.sin() * r)); + ring.push((c + u * (a.cos() * r) + v * (a.sin() * r), false)); } } } } -fn polygon(out: &mut BezPath, mut pts: impl Iterator) { - let Some(first) = pts.next() else { return }; - out.move_to(first); - for p in pts { - out.line_to(p); +/// Append `pts` as a closed contour (a width outline's ring or loop), through a smooth run's +/// points as cubic curves. Sampled as line segments, a curved, wide stroke shows the facets of +/// its offset whenever it is magnified; the cubics keep it smooth. A run whose points are +/// collinear stays a line, and the points marked sharp — caps, miter joins, width steps and path +/// corners — stay sharp. A rapid width change turns the offset sharply without being a corner: +/// marking corners here rather than measuring turns keeps that turn smooth. +fn polygon(out: &mut BezPath, pts: impl Iterator, tol: f64) { + let mut ring: Vec<(Point, bool)> = Vec::new(); + for (p, sharp) in pts { + if !p.x.is_finite() || !p.y.is_finite() { + return; + } + if let Some(last) = ring.last_mut() + && last.0.distance(p) <= 1e-9 + { + last.1 |= sharp; + continue; + } + ring.push((p, sharp)); + } + if ring.len() > 1 + && ring[0].0.distance(ring[ring.len() - 1].0) <= 1e-9 + && let Some(last) = ring.pop() + { + ring[0].1 |= last.1; + } + let n = ring.len(); + if n == 0 { + return; + } + if n < 3 { + out.move_to(ring[0].0); + if n == 2 { + out.line_to(ring[1].0); + } + out.close_path(); + return; + } + // Start at a sharp point when there is one, so no run has to wrap: `smooth_run` starts from + // whichever point is current, and starting anywhere else would bridge over the skipped arc. + match ring.iter().position(|&(_, sharp)| sharp) { + None => { + // A smooth closed loop: fit across the seam, so it doesn't start with a kink. + let pts: Vec = ring.iter().map(|&(p, _)| p).collect(); + out.move_to(pts[0]); + smooth_run(out, &pts, true, tol); + } + Some(first) => { + out.move_to(ring[first].0); + // From each sharp point to the next; the sharp points themselves stay sharp. + let mut i = first; + loop { + let mut j = (i + 1) % n; + while !ring[j].1 { + j = (j + 1) % n; + } + // A contour with a single sharp point comes back to it: the run is the whole + // loop, fitted non-cyclically so that point stays sharp. + let steps = if j == i { n } else { (j + n - i) % n }; + if steps == 1 { + out.line_to(ring[j].0); + } else { + let run: Vec = (0..=steps).map(|k| ring[(i + k) % n].0).collect(); + smooth_run(out, &run, false, tol); + } + if j == first { + break; + } + i = j; + } + } } out.close_path(); } +/// The derivative of the run at each point, in points per point of chord length. Each coordinate +/// is a shape-preserving cubic Hermite (Fritsch–Carlson): between two points it stays within their +/// coordinate range, so a cubic through a fast width pinch cannot bulge past the pinch and cross +/// the other side, and it has a flat slope at a local extremum. A Catmull–Rom tangent (the secant +/// across the neighbours) does neither: it overshoots a V and, with the uneven sample spacing of a +/// flattened path, leaves a scalloped edge. +fn tangents(run: &[Point], cyclic: bool) -> Vec { + let n = run.len(); + let segs = if cyclic { n } else { n.saturating_sub(1) }; + let mut h = vec![0.0; segs]; + for (i, hi) in h.iter_mut().enumerate() { + *hi = run[i].distance(run[(i + 1) % n]); + } + let slope = |d0: f64, d1: f64, h0: f64, h1: f64| { + if d0 * d1 <= 0.0 { + return 0.0; + } + let (w0, w1) = (2.0 * h1 + h0, h1 + 2.0 * h0); + let m = (w0 + w1) / (w0 / d0 + w1 / d1); + if m.is_finite() { m } else { 0.0 } + }; + // The one-sided slope of a run's end uses its two end secants, instead of only the last one. + // A coarse sample then still leaves the end of a profile curve with the right slope. + let end_slope = |d0: f64, d1: f64, h0: f64, h1: f64| { + if d0 * d1 <= 0.0 || h0 + h1 <= 1e-12 { + return 0.0; + } + let m = ((2.0 * h0 + h1) * d0 - h0 * d1) / (h0 + h1); + if m.is_finite() && m * d0 > 0.0 { + let cap = 3.0 * d0.abs(); + m.clamp(-cap, cap) + } else { + 0.0 + } + }; + let delta = |i: usize| -> Vec2 { + let hi = h[i % segs]; + if hi > 1e-12 { (run[(i + 1) % n] - run[i % n]) / hi } else { Vec2::ZERO } + }; + let mut out = vec![Vec2::ZERO; n]; + for (i, m) in out.iter_mut().enumerate() { + let (d0, d1) = (delta((i + n - 1) % n), delta(i % segs)); + let (h0, h1) = (h[(i + n - 1) % segs], h[i % segs]); + if h0 <= 1e-12 || h1 <= 1e-12 { + *m = if h1 > 1e-12 { d1 } else { d0 }; + } else if cyclic || (i > 0 && i + 1 < n) { + *m = Vec2::new(slope(d0.x, d1.x, h0, h1), slope(d0.y, d1.y, h0, h1)); + } else if i == 0 { + *m = if n >= 3 { + let (a, b) = (delta(0), delta(1)); + Vec2::new(end_slope(a.x, b.x, h[0], h[1]), end_slope(a.y, b.y, h[0], h[1])) + } else { + d1 + }; + } else { + *m = if n >= 3 { + let (a, b) = (delta(n - 2), delta(n - 3)); + Vec2::new(end_slope(a.x, b.x, h[n - 2], h[n - 3]), end_slope(a.y, b.y, h[n - 2], h[n - 3])) + } else { + d0 + }; + } + // A shape-preserving slope is flat in a coordinate at that coordinate's local extremum. + // Where both are extrema at one sample (a loop or a pinch in the sampled edge) the flat + // tangent would make a line and a kink; the secant across the sample keeps the curve C1 + // there, and the extremum itself is what trims the loop later. + if m.hypot() <= 1e-12 { + let prev = if cyclic || i > 0 { run[(i + n - 1) % n] } else { run[i] }; + let next = if cyclic || i + 1 < n { run[(i + 1) % n] } else { run[i] }; + let sec = next - prev; + if sec.hypot() > 1e-12 { + *m = sec.normalize(); + } + } + } + out +} + +/// Append a run of points as a fitted chain of cubics (or one line when they are collinear). +/// +/// A cubic through every flattened point, as this used to emit, keeps the outline smooth but puts +/// three coordinates per point into the file; a small, thin stroke then exports several times the +/// path data of the same stroke without a profile. Fit a chain within the outline tolerance +/// instead: the result is still G1 continuous and stays within `tol` of the sampled edge, but a +/// smooth side is a few cubics rather than one per flatten step. Profile bends that need finer +/// detail produce more cubics; straight runs stay lines. +fn smooth_run(out: &mut BezPath, run: &[Point], cyclic: bool, tol: f64) { + let n = run.len(); + if n < 2 { + return; + } + if !cyclic && is_straight(run, tol) { + out.line_to(run[n - 1]); + return; + } + let tans = tangents(run, cyclic); + let tol = tol.max(1e-6); + // A short run is one or two profile features, not flattened path detail. Fitting it loosely + // enough to save path data can let a handle cross the narrow side of a V; keep the + // shape-preserving per-point cubics there. + let segs = if cyclic { n } else { n - 1 }; + if n <= 3 { + for i in 0..segs { + let j = (i + 1) % n; + let h = run[i].distance(run[j]); + let (m0, m1) = (tans[i] * (h / 3.0), tans[j] * (h / 3.0)); + if m0.hypot() <= 1e-12 || m1.hypot() <= 1e-12 { + out.line_to(run[j]); + } else { + out.curve_to(run[i] + m0, run[j] - m1, run[j]); + } + } + return; + } + let unit_or = |v: Vec2, fallback: Vec2| { + let len = v.hypot(); + if len > 1e-12 { + v / len + } else { + let len = fallback.hypot(); + if len > 1e-12 { fallback / len } else { Vec2::new(1.0, 0.0) } + } + }; + let t0 = unit_or(tans[0], run[1] - run[0]); + let (pts, t0, t1) = if cyclic { + // Fit the seam too: the fit starts and arrives with the same tangent, so closing the + // contour adds no kink. + let mut pts = Vec::with_capacity(n + 1); + pts.extend_from_slice(run); + pts.push(run[0]); + (pts, t0, t0) + } else { + (run.to_vec(), t0, unit_or(tans[n - 1], run[n - 1] - run[n - 2])) + }; + // Fit tighter than the flattening tolerance: the fit's error is the whole outline's error, + // and a least-squares fit may sit on one side of the sampled edge, which would otherwise + // shift a profile's area visibly. The points lie on the outline's own curve, so the fit follows + // their curve between them rather than the chords through them. + let cubics = fit_cubics_through(&pts, t0, t1, tol * 0.1); + if cubics.is_empty() { + out.line_to(run[n - 1]); + return; + } + for c in cubics { + out.curve_to(c.p1, c.p2, c.p3); + } +} + +/// Are the run's points within the flattening tolerance of its chord? +fn is_straight(run: &[Point], tol: f64) -> bool { + let (a, b) = (run[0], run[run.len() - 1]); + let d = b - a; + let l = d.hypot(); + if l <= 1e-12 { + return false; + } + run.iter().all(|p| (d.cross(*p - a) / l).abs() <= tol.max(1e-6) * 0.25) +} + #[cfg(test)] mod tests { use super::*; @@ -423,13 +860,12 @@ mod tests { assert!((area(&width_outline(&line(), 10.0, &p, &capped(LineCap::Square), 0.01)) - 1100.0).abs() < 1e-6); } - /// The Lens outline swells on its curve, and a profile with one side at zero paints only the other side. + /// The lens eases up to full width and back down (a parabolic bump averaging two thirds of + /// the width); a one-sided profile keeps its side at zero and stays on the other. #[test] - fn lens_profile_swells_smoothly_and_one_sided_profiles_stay_on_their_side() { - // Each half of the lens rises on a cubic from 0 to full width, flat at the middle: it - // covers 7/12 of the full-width band (a diamond would cover half). + fn lens_profile_is_a_smooth_bump_and_one_sided_profiles_stay_on_their_side() { let a = area(&width_outline(&line(), 10.0, &WidthProfile::lens(), &capped(LineCap::Butt), 0.01)); - assert!((a - 1000.0 * 7.0 / 12.0).abs() < 0.5, "{a}"); + assert!((a - 2000.0 / 3.0).abs() < 0.1, "{a}"); let one = WidthProfile { points: vec![(0.0, 1.0, 0.0), (1.0, 1.0, 0.0)] }; let o = width_outline(&line(), 10.0, &one, &capped(LineCap::Butt), 0.01); assert!((area(&o) - 500.0).abs() < 1e-6); @@ -453,41 +889,103 @@ mod tests { assert!(a.is_finite() && (a - 3000.0).abs() < 150.0, "{a}"); } - /// #5714: between width points a straight run follows the profile's smooth curve, not a - /// straight line from one width to the next. #[test] - fn a_straight_run_follows_the_curve_between_width_points() { - // 5 pt either side at the ends, 15 pt at the middle: a quarter of the way along the curve - // is at 2.25 × 5 = 11.25 pt (a straight ramp would be at 10 pt). - let p = WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.5, 3.0, 3.0), (1.0, 1.0, 1.0)] }; - let o = width_outline(&line(), 10.0, &p, &capped(LineCap::Butt), 0.01); - for y in [-11.0, 11.0] { - assert!(o.contains(kurbo::Point::new(25.0, y)), "inside at y {y}"); - } - for y in [-11.5, 11.5] { - assert!(!o.contains(kurbo::Point::new(25.0, y)), "outside at y {y}"); - } - // Round the widest point the side is flat: no corner sticks out above 15 pt. - let bb = o.bounding_box(); - assert!((bb.y0 + 15.0).abs() < 1e-6 && (bb.y1 - 15.0).abs() < 1e-6, "{bb:?}"); + fn a_closed_path_steps_where_its_profile_ends_differ() { + let mut sq = BezPath::new(); + sq.move_to((0.0, 0.0)); + sq.line_to((100.0, 0.0)); + sq.line_to((100.0, 100.0)); + sq.line_to((0.0, 100.0)); + sq.close_path(); + // 5 pt at the seam's start, 10 pt at its end: the ring must step between them, not + // stretch the last width point round the corner. + let p = WidthProfile { points: vec![(0.0, 1.0, 1.0), (1.0, 2.0, 2.0)] }; + let o = width_outline(&sq, 10.0, &p, &capped(LineCap::Butt), 0.01); + let at = |p: Point, x: f64, y: f64| (p.x - x).abs() < 1e-9 && (p.y - y).abs() < 1e-9; + let step = o.elements().windows(2).any(|w| match (w[0], w[1]) { + (PathEl::MoveTo(a), PathEl::LineTo(b)) | (PathEl::LineTo(a), PathEl::LineTo(b)) => { + at(a, -10.0, -10.0) && at(b, -5.0, -5.0) || at(a, -5.0, -5.0) && at(b, -10.0, -10.0) + } + _ => false, + }); + assert!(step, "{o:?}"); } - /// A taper is a straight ramp: its outline gets no samples between the width points. #[test] - fn a_straight_taper_gets_no_samples_between_its_points() { - let o = width_outline(&line(), 10.0, &WidthProfile::taper_end(), &capped(LineCap::Butt), 0.01); - assert_eq!(o.elements().len(), 5, "{:?}", o.elements()); + fn added_samples_follow_the_curve_not_the_flattened_chord() { + // A cubic with a tight S: its chords are far from its tangents. A sample inserted inside a + // chord must take the curve's direction there; the chord's direction is what made a wide + // profile scallop at every flatten vertex. + let mut b = BezPath::new(); + b.move_to((0.0, 0.0)); + b.curve_to((0.0, 120.0), (120.0, -120.0), (120.0, 0.0)); + let segs = segments(b.elements()); + let flat = flatten(&segs, false, 2.0); + assert!(flat.pts.len() >= 3); + let mut marks = vec![]; + let mut acc = 0.0; + for i in 0..flat.pts.len() - 1 { + let len = flat.pts[i].distance(flat.pts[i + 1]); + marks.push(acc + len / 2.0); + acc += len; + } + let (all, _, dirs) = split_at(&flat, false, &marks, &segs); + assert!(all.len() > flat.pts.len()); + let seg = segs[0]; + for (p, d) in all.iter().zip(&dirs) { + let want = tangent(&seg, seg.nearest(*p, 1e-9).t); + assert!(d.dot(want) > 0.999, "{d:?} vs {want:?} at {p:?}"); + } } - /// A profile read from a file may have thousands of points and absurd widths: the samples added - /// between them stay bounded and the outline finite. #[test] - fn samples_between_width_points_are_capped() { - let points = (0..=10_000).map(|i| (i as f64 / 10_000.0, if i % 2 == 0 { 0.0 } else { 1e12 }, 1.0)).collect(); - let o = width_outline(&line(), 10.0, &WidthProfile { points }, &capped(LineCap::Butt), 0.01); - let n = o.elements().len(); - assert!(n < 2 * (2 * 10_001 + MAX_CURVE_SAMPLES) + 64, "{n}"); - assert!(o.bounding_box().area().is_finite()); + fn a_wiggly_profile_on_a_curve_fits_without_kinks() { + // A rapid width change turns the offset sharply; that is not a path corner, so the + // outline must stay tangent-continuous through it (the old turn-angle test did not). + let c = kurbo::Circle::new((0.0, 0.0), 50.0).to_path(1e-3); + let p = WidthProfile { + points: vec![(0.0, 1.0, 1.0), (0.15, 3.0, 0.4), (0.3, 0.2, 2.8), (0.5, 2.5, 0.3), (0.7, 0.4, 2.2), (0.85, 2.0, 0.5), (1.0, 1.0, 1.0)], + }; + let o = width_outline(&c, 10.0, &p, &capped(LineCap::Butt), 0.01); + let mut cursor = None; + let mut prev: Option = None; + let mut kinks = 0; + let mut check = |a: Vec2, b: Vec2| { + let (la, lb) = (a.hypot(), b.hypot()); + if la > 1e-9 && lb > 1e-9 && (a.dot(b) <= 0.0 || a.cross(b).abs() / (la * lb) > 1e-6) { + kinks += 1; + } + }; + for el in o.elements() { + match el { + PathEl::MoveTo(p) => { + cursor = Some(*p); + prev = None; + } + PathEl::LineTo(p) => { + if let Some(q) = cursor { + let d = *p - q; + if let Some(a) = prev { + check(a, d); + } + prev = Some(d); + } + cursor = Some(*p); + } + PathEl::CurveTo(a, b, p) => { + if let Some(q) = cursor { + let d = if a.distance(q) > 1e-9 { *a - q } else { *b - q }; + if let Some(prev) = prev { + check(prev, d); + } + prev = Some(if p.distance(*b) > 1e-9 { *p - *b } else { *p - *a }); + } + cursor = Some(*p); + } + PathEl::QuadTo(_, _) | PathEl::ClosePath => {} + } + } + assert_eq!(kinks, 0, "{o:?}"); } #[test] @@ -512,4 +1010,108 @@ mod tests { let want = 2.0 * std::f64::consts::PI * 50.0 * 10.0; assert!((a - want).abs() / want < 0.01, "{a} vs {want}"); } + + #[test] + fn a_uniform_circle_narrower_than_its_stroke_has_no_hole() { + // Half-width 15 on a radius-5 circle: the inner offset crosses the centre. The constant + // stroke still covers the centre, so the outline must not leave a hole there. + let c = kurbo::Circle::new((0.0, 0.0), 5.0).to_path(1e-3); + let p = WidthProfile { points: vec![(0.0, 1.0, 1.0), (1.0, 1.0, 1.0)] }; + let o = width_outline(&c, 30.0, &p, &capped(LineCap::Butt), 0.01); + assert!(o.contains(Point::new(0.0, 0.0)), "{o:?}"); + } + + #[test] + fn a_quick_width_change_does_not_overshoot_its_samples() { + // Width falls to a point and comes back, unevenly on either side: the fitted edge must not + // dip past the point (that crossed the other edge and left a scalloped neck). + let mut run = vec![Point::new(0.0, 0.0), Point::new(10.0, -1.0), Point::new(20.0, -0.3)]; + let (mut a, mut b) = (BezPath::new(), BezPath::new()); + a.move_to(run[0]); + b.move_to(run[0]); + smooth_run(&mut a, &run, false, 0.0); + // Reverse the same points: the fit must stay inside the V from either direction too. + run.reverse(); + smooth_run(&mut b, &run, false, 0.0); + for path in [a, b] { + let mut start = Point::ZERO; + for el in path.elements() { + match el { + PathEl::MoveTo(p) => start = *p, + PathEl::CurveTo(c1, c2, p) => { + for c in [c1, c2] { + assert!(c.y >= -1.0 - 1e-9, "{c:?} in {path:?}"); + assert!(c.y <= start.y.max(p.y) + 1e-9, "{c:?} in {path:?}"); + } + start = *p; + } + _ => {} + } + } + } + } + + #[test] + fn added_samples_stay_on_their_flattened_branch() { + // This cubic loops back near itself. At a coarse tolerance a chord midpoint can be closer + // to the other branch; a full-segment nearest would then take that branch's tangent and + // spike the offset. Each inserted sample must use the parameter range of its own piece. + let mut b = BezPath::new(); + b.move_to((0.0, 0.0)); + b.curve_to((150.0, 100.0), (-50.0, 100.0), (100.0, 0.0)); + let segs = segments(b.elements()); + let seg = segs[0]; + let flat = flatten(&segs, false, 2.0); + let mut acc = 0.0; + let mut divergent = 0; + for i in 0..flat.pts.len() - 1 { + let len = flat.pts[i].distance(flat.pts[i + 1]); + if len <= 1e-12 { + continue; + } + let (t0, t1) = flat.param[i]; + let sub = seg.subsegment(t0..t1); + for k in 1..16 { + let (out, _, dirs) = split_at(&flat, false, &[acc + len * k as f64 / 16.0], &segs); + assert!(out.len() >= i + 2, "no sample inserted in piece {i}"); + let p = out[i + 1]; + let want = tangent(&seg, t0 + (t1 - t0) * sub.nearest(p, 1e-9).t); + assert!(dirs[i + 1].dot(want) > 0.999, "piece {i}, k {k}: {:?} vs {want:?}", dirs[i + 1]); + let full = tangent(&seg, seg.nearest(p, 1e-9).t); + if full.dot(want) < 0.9 { + divergent += 1; + } + } + acc += len; + } + assert!(divergent > 0, "the fixture did not cross the other branch"); + } + + #[test] + fn a_coarse_wide_circle_keeps_its_offset() { + // A wide stroke flattened coarsely: offsetting each flattened segment by its miter + // (`w / cos(θ/2)`) scalloped the circle outward at every sample. Smooth points follow the + // curve's normal instead, so the ring is the annulus between radii 10 and 30. + let c = kurbo::Circle::new((0.0, 0.0), 20.0).to_path(0.5); + let p = WidthProfile { points: vec![(0.0, 1.0, 1.0), (1.0, 1.0, 1.0)] }; + let a = area(&width_outline(&c, 20.0, &p, &capped(LineCap::Butt), 0.5)); + let want = std::f64::consts::PI * (30.0 * 30.0 - 10.0 * 10.0); + assert!((a - want).abs() / want < 0.01, "{a} vs {want}"); + } + + #[test] + fn profiled_outlines_stay_compact() { + // A smooth profile is fitted with cubics, so it must not turn every profile sample into + // exported path data. Compare it with the same curved path's uniform outline. + let circle = kurbo::Circle::new((0.0, 0.0), 5.0).to_path(0.01); + let count = |points| { + let p = WidthProfile { points }; + width_outline(&circle, 5.0, &p, &capped(LineCap::Butt), 0.01).elements().len() + }; + let uniform = count(vec![(0.0, 1.0, 1.0), (1.0, 1.0, 1.0)]); + let lens = count(WidthProfile::lens().points); + let wave = count(vec![(0.0, 0.3, 0.3), (0.25, 1.0, 1.0), (0.5, 0.3, 0.3), (0.75, 1.0, 1.0), (1.0, 0.3, 0.3)]); + assert!(lens <= uniform * 3 / 2, "lens {lens} vs uniform {uniform}"); + assert!(wave <= uniform * 3 / 2, "wave {wave} vs uniform {uniform}"); + } } diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 72e33b899..b8c1f82e5 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -749,6 +749,7 @@ pub(crate) fn unique_name_from(base: &str, next: &mut std::collections::HashMap< /// The most presets one presets file imports: each is looked up among the saved ones. pub(crate) const MAX_IMPORTED_PRESETS: usize = 1000; +pub(crate) use addons::DecodedImages; pub use docmenu::guide_indexes; pub(crate) use edit::{settle_interaction, typing_in_progress}; pub(crate) use object::bounds_rewrite; diff --git a/crates/engine/src/cmd/addons.rs b/crates/engine/src/cmd/addons.rs index 324d7bde5..4c4671f6b 100644 --- a/crates/engine/src/cmd/addons.rs +++ b/crates/engine/src/cmd/addons.rs @@ -6,12 +6,15 @@ mod cleanup; mod color; mod contour; mod document; +mod image; mod pathtools; mod pen; mod select; mod shapes; mod typetools; +pub(crate) use image::DecodedImages; + use super::CommandSpec; /// The commands of every built-in add-on. @@ -21,6 +24,7 @@ pub fn specs() -> Vec { v.extend(color::specs()); v.extend(contour::specs()); v.extend(document::specs()); + v.extend(image::specs()); v.extend(pathtools::specs()); v.extend(pen::specs()); v.extend(select::specs()); diff --git a/crates/engine/src/cmd/addons/image.rs b/crates/engine/src/cmd/addons/image.rs new file mode 100644 index 000000000..524f6b205 --- /dev/null +++ b/crates/engine/src/cmd/addons/image.rs @@ -0,0 +1,598 @@ +//! Image Tools (`addon.image.*`), a built-in add-on. Remove Background makes the background of the +//! selected images transparent in their own pixels, as one undo step. The background is the most +//! common color along an image's edges, the average of its four corners, or colors the caller +//! picks. It is cleared where it reaches in from an edge, or wherever it is. The work is done here, +//! on the pixels; nothing is sent anywhere. It is a VectorCraft addition. Its submenu (Object › +//! Plug-ins › Image Tools) and dialog are the UI's. + +#[cfg(test)] +mod tests; + +use std::collections::{HashMap, HashSet}; +use std::io::Cursor; +use std::sync::{Arc, Weak}; + +use image::{DynamicImage, ImageBuffer, LumaA, Rgba}; +use serde_json::{Value, json}; +use vectorcraft_color::Color; +use vectorcraft_doc::{ImageBlob, NodeId, NodeKind}; +use vectorcraft_geom::Affine; + +use super::super::fileio::{MAX_RASTER_ALLOC, ppi}; +use super::super::menucmds::has_image; +use super::super::{CommandSpec, Result, Session, bad, checked_ids_param, cmd, hex_with_alpha, number_or, switch_or, text_param}; + +/// Remove Background's command. +pub const REMOVE_BACKGROUND: &str = "addon.image.removeBackground"; +const C: &str = REMOVE_BACKGROUND; + +/// The most colors `colors` lists. +const MAX_COLORS: usize = 16; + +/// Bytes a pixel takes while it is worked on, beside its pixels: its level, whether the edge search +/// reached it, and its place in the search's stack (4 bytes, twice that while the stack grows). +const WORK_BYTES: u64 = 10; + +pub fn specs() -> Vec { + vec![cmd!( + "addon.image.removeBackground", + "Remove Background…", + ["Object", "Plug-ins", "Image Tools"], + None, + "{background?: \"edges\" (default: the most common color along the image's edges; nothing is cleared when the edges hold at least as many clear pixels as pixels of that color) | \"corners\" (the average of the image's four corner pixels that are not clear; nothing is cleared when all four are clear) | \"colors\" (the colors in `colors`), colors?: [color…] or one string of them split by commas or spaces (1 to 16 colors, each a hex color \"#rrggbb\" or \"#rgb\" with or without the # or one of black, white, red, green and blue; needed with background: colors, and checked but not used with edges or corners), contiguous?: bool (true: only background pixels that reach in from an edge are cleared; false: they are cleared wherever they are; true), match?: \"distance\" (default: a color's RGB distance from a background color) | \"channels\" (its largest difference on one of red, green and blue), tolerance?: 0..100 (with distance, % of the largest RGB distance; with channels, % of 255 steps on one channel; a pixel this near a background color is cleared; 10), softness?: 0..100 (% past the tolerance over which pixels fade from cleared to kept; 10), ids?: [id…] (default: the selected images)} make the background of each image transparent in its own pixels, as one undo step: the image stays an image, embedded as a PNG with an alpha channel at the depth of its pixels (16-bit images keep 16 bits and grayscale images stay gray) with the source's color profile and resolution, and a linked image is embedded; a CMYK image, a linked image that shows its preview (embed or relink it first), an image too large to change, and a result with no pixel left are refused, and an image whose pixels do not change is left as it is (no undo step when none changes) → {changed: the images changed, ids, cleared: the pixels made fully clear in them}", + has_image, + remove_background + )] +} + +/// Where the background's colors come from. +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum Background { + /// The most common color along the image's edges. + Edges, + /// The average of the image's four corner pixels that are not clear. + Corners, + /// These colors. + Colors(Vec<[u8; 3]>), +} + +/// How near a color is to a background color. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum Match { + /// The RGB distance, as a percentage of the largest one (black to white). + Distance, + /// The largest difference on one of red, green and blue, as a percentage of a channel's range. + Channels, +} + +/// What Remove Background clears: the background, whether only where it reaches in from an edge, +/// and how near a color must be to it (a percentage, measured as `measure` says) to be cleared, and +/// to fade out past that. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Settings { + pub background: Background, + pub contiguous: bool, + pub measure: Match, + pub tolerance: f64, + pub softness: f64, +} + +impl Settings { + /// The settings `p` gives, each checked. + fn read(p: &Value) -> Result { + let percent = |key: &str| -> Result { + let v = number_or(p, key, 10.0, C)?; + if v.is_finite() && (0.0..=100.0).contains(&v) { Ok(v) } else { Err(bad(C, format!("`{key}` must be from 0 to 100"))) } + }; + let (tolerance, softness) = (percent("tolerance")?, percent("softness")?); + let colors = match p.get("colors") { + None | Some(Value::Null) => None, + Some(v) => Some(colors(v)?), + }; + let background = match text_param(p, "background", C)? { + None | Some("edges") => Background::Edges, + Some("corners") => Background::Corners, + Some("colors") => Background::Colors(colors.ok_or_else(colors_error)?), + Some(_) => return Err(bad(C, "`background` must be edges, corners or colors")), + }; + let measure = match text_param(p, "match", C)? { + None | Some("distance") => Match::Distance, + Some("channels") => Match::Channels, + Some(_) => return Err(bad(C, "`match` must be distance or channels")), + }; + let contiguous = switch_or(p, "contiguous", true, C)?; + Ok(Self { background, contiguous, measure, tolerance, softness }) + } +} + +fn colors_error() -> crate::EngineError { + bad(C, "`colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue") +} + +/// The colors `v` lists: an array of strings, or one string of them split by commas or spaces. +/// Each is read as [`Color::from_hex`] reads it; one with an alpha part is refused. +fn colors(v: &Value) -> Result> { + // One past the most is enough to tell a list that is too long. + let texts: Vec<&str> = match v { + Value::String(s) => s.split([',', ' ', '\t', '\n']).filter(|t| !t.is_empty()).take(MAX_COLORS + 1).collect(), + Value::Array(a) => a.iter().take(MAX_COLORS + 1).map(Value::as_str).collect::>().ok_or_else(colors_error)?, + _ => return Err(colors_error()), + }; + if texts.is_empty() || texts.len() > MAX_COLORS { + return Err(colors_error()); + } + texts.into_iter().map(|t| rgb8(t).ok_or_else(colors_error)).collect() +} + +/// The 8-bit RGB of the color `t` writes. +fn rgb8(t: &str) -> Option<[u8; 3]> { + if hex_with_alpha(t) { + return None; + } + let [r, g, b] = Color::from_hex(t)?.to_rgb_uncalibrated(); + Some([r, g, b].map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)) +} + +fn remove_background(s: &mut Session, p: &Value) -> Result { + let settings = Settings::read(p)?; + // A preview step keeps the pixels it decodes for the next step; other runs keep none. + let mut kept = std::mem::take(&mut s.decoded_images); + let previewing = s.previewing; + let found = changes(s, p, &settings, previewing.then_some(&mut kept)); + s.decoded_images = kept; + let changes = found?; + let ids: Vec = changes.iter().map(|(id, ..)| id.0).collect(); + let made_clear: u64 = changes.iter().map(|(_, out, _)| out.cleared).sum(); + if !changes.is_empty() { + s.edit("Remove Background", |d, _| { + for (id, out, xf) in changes { + let key = out.blob.content_key(); + d.images.entry(key.clone()).or_insert(out.blob); + if let Some(NodeKind::Image(im)) = d.node_mut(id).map(|n| &mut n.kind) { + im.key = key; + (im.width, im.height, im.xf) = (out.width, out.height, xf); + im.link = None; + } + } + Ok(()) + })?; + } + Ok(json!({ "changed": ids.len(), "ids": ids, "cleared": made_clear })) +} + +/// The images `p` names (`ids`, or the selected images) whose pixels change, each with its pixels +/// cleared as `settings` say and the transform that keeps its place and size. Decoded pixels are +/// taken from and kept in `kept` when it is given. +fn changes(s: &Session, p: &Value, settings: &Settings, mut kept: Option<&mut DecodedImages>) -> Result> { + let st = s.doc()?; + let ids: Vec = if p.get("ids").is_some_and(|v| !v.is_null()) { + // Each image once, however often `ids` names it. + let mut given = HashSet::new(); + let ids: Vec = checked_ids_param(s, p, "ids", C)?.into_iter().filter(|id| given.insert(*id)).collect(); + if let Some(id) = ids.iter().find(|id| !matches!(st.doc.node(**id).map(|n| &n.kind), Some(NodeKind::Image(_)))) { + return Err(bad(C, format!("object {} is not an image", id.0))); + } + ids + } else { + st.selection.objects.iter().copied().filter(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Image(_)))).collect() + }; + if ids.is_empty() { + return Err(bad(C, "select an image")); + } + // Every image is checked before any is decoded. + let mut images = vec![]; + for id in ids { + let Some(NodeKind::Image(im)) = st.doc.node(id).map(|n| &n.kind) else { continue }; + let blob = st.doc.images.get(&im.key).ok_or_else(|| bad(C, "the image's pixels are missing"))?; + // The preview stands in for a file that hasn't been read, or that is kept at its preview. + if im.link.is_some() && blob.is_proxy() { + return Err(bad(C, "the linked image shows its preview: embed or relink it first")); + } + if blob.is_cmyk() { + return Err(bad(C, "a CMYK image has no transparency to set")); + } + images.push((id, im, blob)); + } + // Each image's pixels once, however many objects show them. + let mut cleared: HashMap<&str, Option> = HashMap::new(); + let mut changes = vec![]; + for (id, im, blob) in images { + let out = match cleared.get(im.key.as_str()) { + Some(out) => out.clone(), + None => { + let out = clear(blob, settings, kept.as_deref_mut())?; + cleared.insert(&im.key, out.clone()); + out + } + }; + if let Some(out) = out { + // A file read at another size than the object's keeps the object's place and size. + let xf = if (out.width, out.height) == (im.width, im.height) { + im.xf + } else { + im.xf * Affine::scale_non_uniform(f64::from(im.width) / f64::from(out.width), f64::from(im.height) / f64::from(out.height)) + }; + changes.push((id, out, xf)); + } + } + Ok(changes) +} + +/// An image with its background cleared: its PNG, its size in pixels, and how many of its pixels +/// were made fully clear. +#[derive(Clone, Debug)] +pub(crate) struct Cleared { + pub blob: ImageBlob, + pub width: u32, + pub height: u32, + pub cleared: u64, +} + +/// An image's pixels as straight RGBA, at 8 or 16 bits a sample. +#[derive(Clone)] +enum Pixels { + Eight(image::RgbaImage), + Sixteen(ImageBuffer, Vec>), +} + +/// The most bytes of decoded pixels and ICC profiles [`DecodedImages`] keeps (128 MiB, or 32 MiB in +/// the web app). +const KEEP_BYTES: u64 = MAX_RASTER_ALLOC / 16; + +/// The images Remove Background decoded during an interaction's preview steps, the most recently +/// used first, at most [`KEEP_BYTES`] in all. The next preview step runs the command again on the +/// same bytes and takes their pixels from here instead of decoding them again. The session holds +/// it and empties it when the interaction ends (OK or Cancel). A run that is not a preview step +/// does not read or fill it. +#[derive(Default)] +pub(crate) struct DecodedImages(Vec); + +/// An image decoded by [`clear`]: the bytes it was decoded from (held weakly, so they are not kept +/// alive for it, and an entry whose bytes are gone is dropped), its pixels, whether it was gray, +/// and its ICC profile. +struct Decoded { + source: Weak>, + pixels: Arc, + gray: bool, + icc: Option>, +} + +#[cfg(test)] +thread_local! { + /// How many times this thread decoded an image in [`clear`]. + pub(crate) static DECODES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +impl DecodedImages { + /// Drops every image kept. + pub(crate) fn empty(&mut self) { + self.0.clear(); + } + + /// How many images are kept. + #[cfg(test)] + pub(crate) fn len(&self) -> usize { + self.0.len() + } + + /// The pixels, gray flag and ICC profile kept for `bytes`, if they are kept. Images whose bytes + /// are gone are dropped first. + fn get(&mut self, bytes: &Arc>) -> Option<(Arc, bool, Option>)> { + self.0.retain(|d| d.source.strong_count() > 0); + let at = self.0.iter().position(|d| std::ptr::eq(d.source.as_ptr(), Arc::as_ptr(bytes)))?; + let d = self.0.remove(at); + let found = (Arc::clone(&d.pixels), d.gray, d.icc.clone()); + self.0.insert(0, d); + Some(found) + } + + /// Keeps `pixels` decoded from `bytes` first, in place of anything kept for the same bytes, and + /// drops images whose bytes are gone and the least recently used past [`KEEP_BYTES`]. + fn keep(&mut self, bytes: &Arc>, pixels: Arc, gray: bool, icc: Option>) { + self.0.retain(|d| d.source.strong_count() > 0 && !std::ptr::eq(d.source.as_ptr(), Arc::as_ptr(bytes))); + self.0.insert(0, Decoded { source: Arc::downgrade(bytes), pixels, gray, icc }); + let mut total = 0u64; + self.0.retain(|d| { + total = total.saturating_add(d.bytes()); + total <= KEEP_BYTES + }); + } +} + +impl Decoded { + /// How many bytes the entry keeps: its pixels and its ICC profile. + fn bytes(&self) -> u64 { + self.pixels.bytes().saturating_add(self.icc.as_ref().map_or(0, |icc| icc.len() as u64)) + } +} + +impl Pixels { + /// How many bytes the pixels take. + fn bytes(&self) -> u64 { + match self { + Self::Eight(img) => img.as_raw().len() as u64, + Self::Sixteen(img) => img.as_raw().len() as u64 * 2, + } + } + + /// The width and height in pixels. + fn dimensions(&self) -> (u32, u32) { + match self { + Self::Eight(img) => img.dimensions(), + Self::Sixteen(img) => img.dimensions(), + } + } + + /// Pixel `i` (row by row from the top left) at 16 bits a sample (an 8-bit sample `v` is + /// `v * 257`). + fn rgba16(&self, i: usize) -> [u16; 4] { + let mut out = [0u16; 4]; + match self { + Self::Eight(img) => { + for (o, v) in out.iter_mut().zip(img.as_raw().get(i * 4..i * 4 + 4).unwrap_or_default()) { + *o = u16::from(*v) * 257; + } + } + Self::Sixteen(img) => { + for (o, v) in out.iter_mut().zip(img.as_raw().get(i * 4..i * 4 + 4).unwrap_or_default()) { + *o = *v; + } + } + } + out + } +} + +/// `blob` with its background cleared as `settings` say, or `None` when no pixel changes. A CMYK +/// image, one that can't be decoded or is too large to change, and a result with no pixel left are +/// errors. The PNG keeps the source's ICC profile and resolution. The decoded pixels are taken +/// from and kept in `kept` when it is given. +pub(crate) fn clear(blob: &ImageBlob, settings: &Settings, mut kept: Option<&mut DecodedImages>) -> Result> { + if blob.is_cmyk() { + return Err(bad(C, "a CMYK image has no transparency to set")); + } + let found = kept.as_deref_mut().and_then(|k| k.get(&blob.bytes)); + let (source, gray, icc) = match found { + Some(found) => found, + None => decode(blob, kept)?, + }; + // The pixels kept for the next run stay as they are; ones not kept are worked on in place. + let mut px = Arc::try_unwrap(source).unwrap_or_else(|kept| Pixels::clone(&kept)); + let (w, h) = px.dimensions(); + let levels = levels(w as usize, h as usize, &|i| px.rgba16(i), settings); + let Some(cleared) = apply(&mut px, &levels) else { return Ok(None) }; + let left = match &px { + Pixels::Eight(img) => img.pixels().any(|p| p.0[3] > 0), + Pixels::Sixteen(img) => img.pixels().any(|p| p.0[3] > 0), + }; + if !left { + return Err(bad(C, "every pixel matches the background: no image would be left")); + } + let out = match (px, gray) { + (Pixels::Eight(img), false) => DynamicImage::ImageRgba8(img), + (Pixels::Sixteen(img), false) => DynamicImage::ImageRgba16(img), + (Pixels::Eight(img), true) => { + DynamicImage::ImageLumaA8(ImageBuffer::from_fn(w, h, |x, y| LumaA([img.get_pixel(x, y).0[0], img.get_pixel(x, y).0[3]]))) + } + (Pixels::Sixteen(img), true) => { + DynamicImage::ImageLumaA16(ImageBuffer::from_fn(w, h, |x, y| LumaA([img.get_pixel(x, y).0[0], img.get_pixel(x, y).0[3]]))) + } + }; + let mut png = Vec::new(); + let mut encoder = image::codecs::png::PngEncoder::new(&mut png); + if let Some(icc) = icc { + use image::ImageEncoder as _; + encoder.set_icc_profile(icc).map_err(|e| bad(C, e.to_string()))?; + } + out.write_with_encoder(encoder).map_err(|e| bad(C, e.to_string()))?; + // The resolution the file declares, which sizes the image at 100% when it is replaced or relinked. + if let Some(r) = ppi::resolution(&blob.bytes) { + png = ppi::with_png_resolution(&png, r); + } + Ok(Some(Cleared { blob: ImageBlob::png(png), width: w, height: h, cleared })) +} + +/// `blob`'s pixels as straight RGBA, whether they were gray, and its ICC profile. An image that +/// can't be decoded or is too large to change is an error. The pixels are kept in `kept`, when it +/// is given, if a copy of them fits the decode allowance beside the work. +fn decode(blob: &ImageBlob, kept: Option<&mut DecodedImages>) -> Result<(Arc, bool, Option>)> { + let reader = + || image::ImageReader::new(Cursor::new(blob.bytes.as_slice())).with_guessed_format().map_err(|_| bad(C, "the image's pixels can't be read")); + let mut decoder = reader()?.into_decoder().map_err(|e| bad(C, format!("the image's pixels can't be read: {e}")))?; + // The ICC profile the colors are in goes with them into the PNG. + let (w, h, kind, icc) = { + use image::ImageDecoder as _; + let (w, h) = decoder.dimensions(); + (w, h, decoder.color_type(), decoder.icc_profile().ok().flatten()) + }; + drop(decoder); + // The file's pixels, those worked on (as many again for the PNG made of them) and the work, + // checked from the header before anything is decoded. + let deep = kind.bytes_per_pixel() > kind.channel_count(); + let per_pixel = u64::from(kind.bytes_per_pixel()) + 2 * if deep { 8 } else { 4 } + WORK_BYTES; + if u64::from(w).checked_mul(u64::from(h)).and_then(|n| n.checked_mul(per_pixel)).is_none_or(|n| n > MAX_RASTER_ALLOC) { + return Err(bad(C, format!("an image of {w} × {h} pixels is too large to change"))); + } + let mut reader = reader()?; + let mut limits = image::Limits::default(); + limits.max_alloc = Some(MAX_RASTER_ALLOC); + reader.limits(limits); + let img = reader.decode().map_err(|e| bad(C, format!("the image's pixels can't be read: {e}")))?; + #[cfg(test)] + DECODES.with(|c| c.set(c.get() + 1)); + let gray = matches!(img.color(), image::ColorType::L8 | image::ColorType::La8 | image::ColorType::L16 | image::ColorType::La16); + let px = Arc::new(if deep { Pixels::Sixteen(img.into_rgba16()) } else { Pixels::Eight(img.into_rgba8()) }); + // A kept copy is worked on as a copy: one more set of the pixels worked on. + let with_copy = per_pixel + if deep { 8 } else { 4 }; + if let Some(kept) = kept + && u64::from(w).checked_mul(u64::from(h)).and_then(|n| n.checked_mul(with_copy)).is_some_and(|n| n <= MAX_RASTER_ALLOC) + && px.bytes().saturating_add(icc.as_ref().map_or(0, |icc| icc.len() as u64)) <= KEEP_BYTES + { + kept.keep(&blob.bytes, Arc::clone(&px), gray, icc.clone()); + } + Ok((px, gray, icc)) +} + +/// Each pixel's alpha scaled by its level (255 keeps it). Returns how many pixels were made fully +/// clear, or `None` when no pixel changed. +fn apply(px: &mut Pixels, levels: &[u8]) -> Option { + let (mut changed, mut cleared) = (false, 0u64); + match px { + Pixels::Eight(img) => { + for (p, &l) in img.pixels_mut().zip(levels) { + let a = ((u32::from(p.0[3]) * u32::from(l) + 127) / 255) as u8; + changed |= a != p.0[3]; + cleared += u64::from(a == 0 && p.0[3] != 0); + p.0[3] = a; + } + } + Pixels::Sixteen(img) => { + for (p, &l) in img.pixels_mut().zip(levels) { + let a = ((u32::from(p.0[3]) * u32::from(l) + 127) / 255) as u16; + changed |= a != p.0[3]; + cleared += u64::from(a == 0 && p.0[3] != 0); + p.0[3] = a; + } + } + } + changed.then_some(cleared) +} + +/// How much of each of the `w` × `h` pixels `px` gives (row by row, straight RGBA at 16 bits a +/// sample) stays, from 0 (cleared) to 255 (kept), as `settings` say. Each pixel's distance to the +/// background is worked out once. A pixel past the first `u32::MAX` is left kept by the edge search. +pub(crate) fn levels(w: usize, h: usize, px: &dyn Fn(usize) -> [u16; 4], settings: &Settings) -> Vec { + let n = w.saturating_mul(h); + let refs: Vec<[u16; 3]> = match &settings.background { + Background::Colors(refs) => refs.iter().map(|c| c.map(|v| u16::from(v) * 257)).collect(), + Background::Edges => match edge_color(w, h, px) { + Some(edge) => vec![edge], + None => return vec![255; n], + }, + Background::Corners => match corner_color(w, h, px) { + Some(corner) => vec![corner], + None => return vec![255; n], + }, + }; + let mut out: Vec = (0..n) + .map(|i| { + let [r, g, b, _] = px(i); + level([r, g, b], &refs, settings) + }) + .collect(); + if settings.contiguous { + // Out from the edges, through clear pixels and pixels that lose some alpha; the pixels it + // doesn't reach are kept. + let reaches = |out: &[u8], i: usize| out.get(i).is_some_and(|l| *l < 255) || px(i)[3] == 0; + let mut seen = vec![false; n]; + let mut stack: Vec = vec![]; + for i in border(w, h).filter(|&i| reaches(&out, i)) { + if let (Some(s), Ok(at)) = (seen.get_mut(i), u32::try_from(i)) { + *s = true; + stack.push(at); + } + } + while let Some(at) = stack.pop() { + let i = at as usize; + let (x, y) = (i % w, i / w); + let next = [(x > 0).then(|| i - 1), (x + 1 < w).then(|| i + 1), (y > 0).then(|| i - w), (y + 1 < h).then(|| i + w)]; + for j in next.into_iter().flatten() { + if let Some(s) = seen.get_mut(j) + && !*s + && reaches(&out, j) + && let Ok(at) = u32::try_from(j) + { + *s = true; + stack.push(at); + } + } + } + for (l, s) in out.iter_mut().zip(&seen) { + if !*s { + *l = 255; + } + } + } + out +} + +/// The level of a pixel of color `c`: 0 within the tolerance of the nearest of `refs`, 255 from +/// the tolerance plus the softness on, and in proportion between them. +fn level(c: [u16; 3], refs: &[[u16; 3]], settings: &Settings) -> u8 { + let Settings { tolerance, softness, measure, .. } = *settings; + let d = refs.iter().map(|r| apart(c, *r, measure)).fold(f64::INFINITY, f64::min); + if d <= tolerance { + 0 + } else if d >= tolerance + softness { + 255 + } else { + ((d - tolerance) / softness * 255.0).round().clamp(0.0, 255.0) as u8 + } +} + +/// How far apart `a` and `b` are, as a percentage, measured as `measure` says: the RGB distance of +/// the largest one (black to white), or the largest difference on one channel of the channel's range. +/// A difference of `k` 8-bit steps on one channel is exactly `k / 255 × 100` with channels. +fn apart(a: [u16; 3], b: [u16; 3], measure: Match) -> f64 { + match measure { + Match::Distance => { + let sq: f64 = a.iter().zip(b).map(|(x, y)| (f64::from(*x) - f64::from(y)).powi(2)).sum(); + sq.sqrt() / (65_535.0 * 3f64.sqrt()) * 100.0 + } + Match::Channels => f64::from(a.iter().zip(b).map(|(x, y)| x.abs_diff(y)).max().unwrap_or(0)) / 65_535.0 * 100.0, + } +} + +/// The pixels on the edges of a `w` × `h` image, each once. +fn border(w: usize, h: usize) -> impl Iterator { + let top = (0..w).filter(move |_| h > 0); + let bottom = (0..w).filter(move |_| h > 1).map(move |x| (h - 1) * w + x); + let sides = (1..h.saturating_sub(1)).flat_map(move |y| [Some(y * w), (w > 1).then(|| y * w + w - 1)].into_iter().flatten()); + top.chain(bottom).chain(sides) +} + +/// The most common color along an image's edges: its edge pixels grouped by 16 levels a channel, +/// with the clear ones as a group of their own, as the average of the largest group. `None` when +/// the clear edge pixels are at least as many as that group: the background is already cleared. +fn edge_color(w: usize, h: usize, px: &dyn Fn(usize) -> [u16; 4]) -> Option<[u16; 3]> { + let mut groups: HashMap<[u16; 3], (u64, [u64; 3])> = HashMap::new(); + let mut clear = 0u64; + for i in border(w, h) { + let [r, g, b, a] = px(i); + if a == 0 { + clear += 1; + continue; + } + let e = groups.entry([r >> 12, g >> 12, b >> 12]).or_default(); + e.0 += 1; + for (s, v) in e.1.iter_mut().zip([r, g, b]) { + *s += u64::from(v); + } + } + // Equal groups go by color, so the result doesn't depend on the map's order. + let (_, (n, sums)) = groups.into_iter().max_by(|a, b| a.1.0.cmp(&b.1.0).then(b.0.cmp(&a.0)))?; + if clear >= n { + return None; + } + Some(sums.map(|s| ((s + n / 2) / n.max(1)) as u16)) +} + +/// The average of the four corner pixels of a `w` × `h` image that are not clear (a corner of a +/// one-pixel-wide image counts twice). `None` when all four are clear: the background is already +/// cleared. +fn corner_color(w: usize, h: usize, px: &dyn Fn(usize) -> [u16; 4]) -> Option<[u16; 3]> { + if w == 0 || h == 0 { + return None; + } + let corners = [0, w - 1, (h - 1) * w, (h - 1) * w + w - 1]; + let (mut n, mut sums) = (0u64, [0u64; 3]); + for [r, g, b, a] in corners.map(px) { + if a > 0 { + n += 1; + for (s, v) in sums.iter_mut().zip([r, g, b]) { + *s += u64::from(v); + } + } + } + (n > 0).then(|| sums.map(|s| ((s + n / 2) / n) as u16)) +} diff --git a/crates/engine/src/cmd/addons/image/tests.rs b/crates/engine/src/cmd/addons/image/tests.rs new file mode 100644 index 000000000..df27180e8 --- /dev/null +++ b/crates/engine/src/cmd/addons/image/tests.rs @@ -0,0 +1,480 @@ +use std::io::Cursor; +use std::sync::Arc; + +use image::{DynamicImage, ImageBuffer, Luma, Rgb}; +use serde_json::{Value, json}; +use vectorcraft_doc::{ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind}; +use vectorcraft_geom::Affine; + +use super::{Background, Match, REMOVE_BACKGROUND as C, Settings, level, levels}; +use crate::Session; +use crate::cmd::fileio::ppi; + +const WHITE: [u8; 4] = [255, 255, 255, 255]; +const RED: [u8; 4] = [200, 0, 0, 255]; + +/// The command's defaults with `background`. +fn settings(background: Background) -> Settings { + Settings { background, contiguous: true, measure: Match::Distance, tolerance: 10.0, softness: 10.0 } +} + +fn png(img: DynamicImage) -> Vec { + let mut out = Vec::new(); + img.write_to(&mut Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out +} + +/// 12 × 10 pixels: white, with a red block from (3, 3) to (9, 7) that holds one white pixel at +/// (6, 5), which no white edge pixel reaches. +fn framed() -> image::RgbaImage { + image::RgbaImage::from_fn(12, 10, |x, y| { + let block = (3..9).contains(&x) && (3..7).contains(&y); + image::Rgba(if block && (x, y) != (6, 5) { RED } else { WHITE }) + }) +} + +/// A session with `bytes` opened as a document, its image selected. +fn open(bytes: &[u8]) -> (Session, NodeId) { + let mut s = Session::new(); + s.execute("document.open", &json!({"name": "art.png", "dataBase64": vectorcraft_format::base64_encode(bytes)})).unwrap(); + s.execute("select.all", &json!({})).unwrap(); + let id = s.doc().unwrap().selection.objects[0]; + (s, id) +} + +fn image_of(s: &Session, id: NodeId) -> ImageObject { + let NodeKind::Image(im) = &s.doc().unwrap().doc.node(id).unwrap().kind else { panic!("an image") }; + (**im).clone() +} + +fn bytes_of(s: &Session, id: NodeId) -> Arc> { + s.doc().unwrap().doc.images[&image_of(s, id).key].bytes.clone() +} + +fn pixels(s: &Session, id: NodeId) -> DynamicImage { + image::load_from_memory(&bytes_of(s, id)).unwrap() +} + +fn alpha(img: &DynamicImage, x: u32, y: u32) -> u8 { + img.to_rgba8().get_pixel(x, y).0[3] +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +#[test] +fn the_edge_colors_are_cleared_where_they_reach_in_from_an_edge() { + let (mut s, id) = open(&png(DynamicImage::ImageRgba8(framed()))); + let before = (image_of(&s, id), undo_len(&s)); + let r = s.execute(C, &json!({})).unwrap(); + assert_eq!(r, json!({"changed": 1, "ids": [id.0], "cleared": 12 * 10 - 6 * 4})); + let out = pixels(&s, id); + assert_eq!(out.color(), image::ColorType::Rgba8); + assert_eq!((alpha(&out, 0, 0), alpha(&out, 11, 9), alpha(&out, 2, 4)), (0, 0, 0), "the white around the block"); + assert_eq!(out.to_rgba8().get_pixel(4, 4).0, RED, "the block"); + assert_eq!(alpha(&out, 6, 5), 255, "white inside the block, which no edge reaches"); + let after = image_of(&s, id); + assert_eq!((after.width, after.height, after.xf), (12, 10, before.0.xf), "the same place and size"); + assert_eq!(undo_len(&s), before.1 + 1, "one undo step"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(image_of(&s, id), before.0); +} + +/// Picked colors, like the other backgrounds, are cleared where they reach in from an edge, and with +/// `contiguous: false` wherever they are. +#[test] +fn picked_colors_are_cleared_from_the_edges_or_wherever_they_are() { + let (mut s, id) = open(&png(DynamicImage::ImageRgba8(framed()))); + s.execute(C, &json!({"background": "colors", "colors": ["#fff"], "tolerance": 0, "softness": 0})).unwrap(); + let out = pixels(&s, id); + assert_eq!((alpha(&out, 0, 0), alpha(&out, 6, 5), alpha(&out, 4, 4)), (0, 255, 255), "not inside the block"); + let (mut s, id) = open(&png(DynamicImage::ImageRgba8(framed()))); + s.execute(C, &json!({"background": "colors", "colors": ["#fff"], "contiguous": false, "tolerance": 0, "softness": 0})).unwrap(); + let out = pixels(&s, id); + assert_eq!((alpha(&out, 0, 0), alpha(&out, 6, 5), alpha(&out, 4, 4)), (0, 0, 255), "inside the block too; the red stays"); +} + +/// With `contiguous: false` the edge color is cleared inside the subject too. +#[test] +fn the_edge_color_can_be_cleared_wherever_it_is() { + let (mut s, id) = open(&png(DynamicImage::ImageRgba8(framed()))); + let r = s.execute(C, &json!({"contiguous": false})).unwrap(); + assert_eq!(r["cleared"], 12 * 10 - 6 * 4 + 1); + let out = pixels(&s, id); + assert_eq!((alpha(&out, 0, 0), alpha(&out, 6, 5), alpha(&out, 4, 4)), (0, 0, 255)); +} + +/// `colors` as one string split by commas and spaces, written as other color params take them. +#[test] +fn picked_colors_can_be_one_string_of_colors() { + for colors in ["#00ff00, ffffff", "green white", "#0f0,#FFF"] { + let (mut s, id) = open(&png(DynamicImage::ImageRgba8(framed()))); + s.execute(C, &json!({"background": "colors", "colors": colors, "contiguous": false, "tolerance": 0, "softness": 0})).unwrap(); + let out = pixels(&s, id); + assert_eq!((alpha(&out, 0, 0), alpha(&out, 6, 5), alpha(&out, 4, 4)), (0, 0, 255), "{colors}"); + } +} + +/// A subject along a whole edge stays: only the most common edge color is the background. +#[test] +fn a_subject_along_one_edge_is_not_the_background() { + let img = image::RgbaImage::from_fn(10, 10, |_, y| image::Rgba(if y >= 7 { RED } else { WHITE })); + let (mut s, id) = open(&png(DynamicImage::ImageRgba8(img))); + s.execute(C, &json!({})).unwrap(); + let out = pixels(&s, id); + assert_eq!((alpha(&out, 5, 0), alpha(&out, 5, 9), alpha(&out, 0, 8)), (0, 255, 255)); +} + +/// The preview steps of one interaction, as the dialog's are, decode an image once and give what a +/// run on freshly decoded pixels gives. The pixels are kept only until the interaction ends (OK or +/// Cancel), and a run that is not a preview step keeps none. +#[test] +fn preview_steps_on_the_same_pixels_decode_them_once() { + let decodes = || super::DECODES.with(std::cell::Cell::get); + let (mut s, id) = open(&png(DynamicImage::ImageRgba8(framed()))); + let start = decodes(); + let p = json!({"background": "colors", "colors": "#c80000", "contiguous": false, "tolerance": 0, "softness": 0}); + s.begin_interaction("Remove Background").unwrap(); + s.preview(C, &json!({"tolerance": 0, "softness": 0})).unwrap(); + s.preview(C, &json!({"tolerance": 20, "softness": 0})).unwrap(); + s.preview(C, &p).unwrap(); + assert_eq!((decodes() - start, s.decoded_images.len()), (1, 1), "decoded once for three preview steps"); + s.commit_interaction().unwrap(); + assert_eq!(s.decoded_images.len(), 0, "OK lets the pixels go"); + let (mut fresh, fresh_id) = open(&png(DynamicImage::ImageRgba8(framed()))); + fresh.execute(C, &p).unwrap(); + assert_eq!((decodes() - start, fresh.decoded_images.len()), (2, 0), "a run that is not a preview step keeps nothing"); + assert_eq!(bytes_of(&s, id), bytes_of(&fresh, fresh_id), "the same result"); + assert_eq!((alpha(&pixels(&s, id), 4, 4), alpha(&pixels(&s, id), 0, 0)), (0, 255), "the red cleared, the white kept"); + s.execute("edit.undo", &json!({})).unwrap(); + s.begin_interaction("Remove Background").unwrap(); + s.preview(C, &json!({})).unwrap(); + assert_eq!(s.decoded_images.len(), 1); + s.cancel_interaction().unwrap(); + assert_eq!(s.decoded_images.len(), 0, "Cancel lets the pixels go"); + s.execute(C, &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute(C, &json!({})).unwrap(); + assert_eq!(decodes() - start, 5, "each run that is not a preview step decodes"); +} + +/// Pixels a preview step takes from the step before keep what they were decoded with: a 16-bit gray +/// image with an ICC profile comes out as a run on freshly decoded pixels gives it. +#[test] +fn reused_pixels_keep_their_depth_gray_and_profile() { + use image::{ImageDecoder as _, ImageEncoder as _}; + let icc = b"a profile's bytes".to_vec(); + let gray16 = ImageBuffer::from_fn(8, 8, |x, y| Luma([if (2..6).contains(&x) && (2..6).contains(&y) { 7_000u16 } else { 61_000 }])); + let mut bytes = Vec::new(); + let mut encoder = image::codecs::png::PngEncoder::new(&mut bytes); + encoder.set_icc_profile(icc.clone()).unwrap(); + DynamicImage::ImageLuma16(gray16).write_with_encoder(encoder).unwrap(); + let decodes = || super::DECODES.with(std::cell::Cell::get); + let (mut s, id) = open(&bytes); + let start = decodes(); + s.begin_interaction("Remove Background").unwrap(); + s.preview(C, &json!({"tolerance": 0, "softness": 0})).unwrap(); + s.preview(C, &json!({})).unwrap(); + assert_eq!(decodes() - start, 1, "the second step took the pixels of the first"); + let (mut fresh, fresh_id) = open(&bytes); + fresh.execute(C, &json!({})).unwrap(); + assert_eq!(bytes_of(&s, id), bytes_of(&fresh, fresh_id), "the same result"); + let DynamicImage::ImageLumaA16(out) = pixels(&s, id) else { panic!("16-bit gray with alpha") }; + assert_eq!((out.get_pixel(0, 0).0, out.get_pixel(3, 3).0), ([61_000, 0], [7_000, 65_535])); + let result = bytes_of(&s, id); + let mut decoder = image::codecs::png::PngDecoder::new(Cursor::new(result.as_slice())).unwrap(); + assert_eq!(decoder.icc_profile().unwrap(), Some(icc)); +} + +/// Once the background is cleared, most edge pixels are clear and a second run clears nothing: the +/// subject's color along an edge is not taken for the background. +#[test] +fn a_second_run_leaves_the_subject() { + let img = image::RgbaImage::from_fn(10, 10, |x, y| { + image::Rgba(if y >= 7 { + RED + } else if (4..6).contains(&x) && (3..5).contains(&y) { + [0, 0, 200, 255] + } else { + WHITE + }) + }); + let (mut s, id) = open(&png(DynamicImage::ImageRgba8(img))); + s.execute(C, &json!({})).unwrap(); + let (first, steps) = (image_of(&s, id), undo_len(&s)); + let r = s.execute(C, &json!({})).unwrap(); + assert_eq!(r, json!({"changed": 0, "ids": [], "cleared": 0})); + assert_eq!((image_of(&s, id), undo_len(&s)), (first, steps), "no change and no undo step"); + let out = pixels(&s, id); + assert_eq!((alpha(&out, 5, 0), alpha(&out, 5, 9), alpha(&out, 4, 3)), (0, 255, 255), "the red and the blue stay"); +} + +#[test] +fn tolerance_and_softness_set_how_near_a_color_is_cleared() { + // White; 1.96 % from white; 21.6 % from white. + let px = [WHITE, [250, 250, 250, 255], [200, 200, 200, 255]]; + let at = |i: usize| px[i].map(|v| u16::from(v) * 257); + let run = |tolerance, softness| levels(3, 1, &at, &Settings { tolerance, softness, ..settings(Background::Colors(vec![[255; 3]])) }); + assert_eq!(run(0.0, 0.0), [0, 255, 255], "only white itself at 0"); + assert_eq!(run(2.0, 0.0), [0, 0, 255]); + assert_eq!(run(0.0, 10.0), [0, 50, 255], "1.96 % into a 10 % fade"); + assert_eq!(run(100.0, 0.0), [0, 0, 0], "everything at 100"); +} + +#[test] +fn a_result_with_no_pixel_left_is_refused_and_changes_nothing() { + let (mut s, id) = open(&png(DynamicImage::ImageRgba8(framed()))); + let before = (image_of(&s, id), undo_len(&s)); + let e = s.execute(C, &json!({"background": "colors", "colors": "#ffffff", "tolerance": 100})).unwrap_err(); + assert!(e.to_string().contains("every pixel matches the background"), "{e}"); + assert_eq!((image_of(&s, id), undo_len(&s)), before); +} + +#[test] +fn an_image_whose_pixels_do_not_change_is_left_with_no_undo_step() { + let (mut s, id) = open(&png(DynamicImage::ImageRgba8(framed()))); + let before = (image_of(&s, id), undo_len(&s)); + let r = s.execute(C, &json!({"background": "colors", "colors": "#00ff00", "tolerance": 0, "softness": 0})).unwrap(); + assert_eq!(r, json!({"changed": 0, "ids": [], "cleared": 0})); + assert_eq!((image_of(&s, id), undo_len(&s)), before); + // An image whose edges and corners are already clear has no edge or corner color to clear, + // whatever color the clear pixels hold. + let ring = + image::RgbaImage::from_fn(6, 6, |x, y| image::Rgba(if (1..5).contains(&x) && (1..5).contains(&y) { WHITE } else { [255, 255, 255, 0] })); + for p in [json!({}), json!({"background": "corners", "contiguous": false})] { + let (mut s, _) = open(&png(DynamicImage::ImageRgba8(ring.clone()))); + assert_eq!(s.execute(C, &p).unwrap()["changed"], 0, "{p}"); + } +} + +/// The corners' color: 40 × 30 pixels of light gray around a red block, cleared within 5 steps on +/// each channel (`match: channels`, a tolerance of `5 / 255 × 100`, no softness), from the edges +/// or wherever it is. +#[test] +fn the_corners_color_is_cleared() { + let img = image::RgbaImage::from_fn(40, 30, |x, y| { + image::Rgba(if (10..30).contains(&x) && (8..22).contains(&y) { [200, 40, 40, 255] } else { [240, 240, 240, 255] }) + }); + for contiguous in [false, true] { + let (mut s, id) = open(&png(DynamicImage::ImageRgba8(img.clone()))); + let p = json!({"background": "corners", "contiguous": contiguous, "match": "channels", "tolerance": 5.0 / 255.0 * 100.0, "softness": 0}); + let r = s.execute(C, &p).unwrap(); + assert_eq!(r["cleared"], 40 * 30 - 14 * 20, "{p}"); + let out = pixels(&s, id).to_rgba8(); + assert_eq!((out.get_pixel(0, 0).0[3], out.get_pixel(15, 15).0), (0, [200, 40, 40, 255]), "{p}"); + } + // A clear corner is left out of the average: the other three are white, and white is cleared. + let mut img = image::RgbaImage::from_pixel(8, 8, image::Rgba(WHITE)); + img.put_pixel(0, 0, image::Rgba([0; 4])); + img.put_pixel(4, 4, image::Rgba(RED)); + let (mut s, id) = open(&png(DynamicImage::ImageRgba8(img))); + let r = s.execute(C, &json!({"background": "corners", "tolerance": 0, "softness": 0})).unwrap(); + let out = pixels(&s, id); + assert_eq!((alpha(&out, 7, 7), alpha(&out, 3, 3), alpha(&out, 4, 4)), (0, 0, 255)); + assert_eq!(r["cleared"], 8 * 8 - 2, "the pixels made clear, not the corner that was clear already"); +} + +/// One picked color matched by channel at a tolerance of 0: cleared wherever it is, and a pixel a +/// long way from it kept. +#[test] +fn one_picked_color_is_cleared_by_channel() { + let mut img = image::RgbaImage::from_pixel(4, 4, image::Rgba([10, 20, 30, 255])); + img.put_pixel(2, 2, image::Rgba([250, 250, 250, 255])); + let (mut s, id) = open(&png(DynamicImage::ImageRgba8(img))); + let p = json!({"background": "colors", "colors": "#0a141e", "contiguous": false, "match": "channels", "tolerance": 0, "softness": 0}); + assert_eq!(s.execute(C, &p).unwrap()["cleared"], 15); + let out = pixels(&s, id); + assert_eq!((alpha(&out, 0, 0), alpha(&out, 2, 2)), (0, 255)); +} + +/// With `match: channels`, a tolerance of `t / 255 × 100` clears a color `k` 8-bit steps from the +/// background on one channel exactly when `k ≤ t`, for every `t` and `k` from 0 to 255; with +/// `distance` the same color is nearer when the others match. +#[test] +fn channels_match_the_largest_difference_on_one_channel() { + for t in 0..=255u16 { + let s = Settings { measure: Match::Channels, tolerance: f64::from(t) / 255.0 * 100.0, softness: 0.0, ..settings(Background::Edges) }; + for k in 0..=255u16 { + for c in [[k * 257, 0, 0], [0, k * 257, 0], [0, 0, k * 257]] { + assert_eq!(level(c, &[[0; 3]], &s) == 0, k <= t, "t {t}, k {k}"); + } + } + } + let at = |measure| { + level([0, 40 * 257, 0], &[[0; 3]], &Settings { measure, tolerance: 32.0 / 255.0 * 100.0, softness: 0.0, ..settings(Background::Edges) }) + }; + assert_eq!((at(Match::Channels), at(Match::Distance)), (255, 0), "40 steps on green: past 32 on one channel; 9 % of the RGB distance"); +} + +#[test] +fn sixteen_bit_and_gray_images_keep_their_depth_and_channels() { + let deep = ImageBuffer::from_fn(8, 8, |x, y| Rgb(if (2..6).contains(&x) && (2..6).contains(&y) { [51_400, 0, 1] } else { [65_535; 3] })); + let (mut s, id) = open(&png(DynamicImage::ImageRgb16(deep))); + s.execute(C, &json!({})).unwrap(); + let DynamicImage::ImageRgba16(out) = pixels(&s, id) else { panic!("16-bit RGBA") }; + assert_eq!(out.get_pixel(0, 0).0[3], 0); + assert_eq!(out.get_pixel(3, 3).0, [51_400, 0, 1, 65_535], "16-bit samples kept as they were"); + // Samples one 8-bit step apart are told apart: only the background is cleared at 0. + let near = ImageBuffer::from_fn(4, 4, |x, y| Rgb(if (1..3).contains(&x) && (1..3).contains(&y) { [65_280; 3] } else { [65_535; 3] })); + let (mut s, id) = open(&png(DynamicImage::ImageRgb16(near))); + s.execute(C, &json!({"tolerance": 0, "softness": 0})).unwrap(); + let DynamicImage::ImageRgba16(out) = pixels(&s, id) else { panic!("16-bit RGBA") }; + assert_eq!((out.get_pixel(0, 0).0[3], out.get_pixel(1, 1).0[3]), (0, 65_535)); + let gray = ImageBuffer::from_fn(8, 8, |x, y| Luma([if (2..6).contains(&x) && (2..6).contains(&y) { 30u8 } else { 240 }])); + let (mut s, id) = open(&png(DynamicImage::ImageLuma8(gray))); + s.execute(C, &json!({})).unwrap(); + let DynamicImage::ImageLumaA8(out) = pixels(&s, id) else { panic!("gray with alpha") }; + assert_eq!((out.get_pixel(0, 0).0, out.get_pixel(3, 3).0), ([240, 0], [30, 255])); + let gray16 = ImageBuffer::from_fn(8, 8, |x, y| Luma([if (2..6).contains(&x) && (2..6).contains(&y) { 7_000u16 } else { 61_000 }])); + let (mut s, id) = open(&png(DynamicImage::ImageLuma16(gray16))); + s.execute(C, &json!({})).unwrap(); + let DynamicImage::ImageLumaA16(out) = pixels(&s, id) else { panic!("16-bit gray with alpha") }; + assert_eq!((out.get_pixel(0, 0).0, out.get_pixel(3, 3).0), ([61_000, 0], [7_000, 65_535])); +} + +/// The ICC profile the colors are in stays with them. +#[test] +fn the_color_profile_is_kept() { + use image::{ImageDecoder as _, ImageEncoder as _}; + let icc = b"a profile's bytes".to_vec(); + let mut bytes = Vec::new(); + let mut encoder = image::codecs::png::PngEncoder::new(&mut bytes); + encoder.set_icc_profile(icc.clone()).unwrap(); + DynamicImage::ImageRgba8(framed()).write_with_encoder(encoder).unwrap(); + let blob = ImageBlob::png(bytes); + let out = super::clear(&blob, &settings(Background::Edges), None).unwrap().unwrap(); + let mut decoder = image::codecs::png::PngDecoder::new(Cursor::new(out.blob.bytes.as_slice())).unwrap(); + assert_eq!(decoder.icc_profile().unwrap(), Some(icc)); +} + +/// The resolution the file declares stays with the pixels, so a later Replace or relink sizes the +/// image as before. +#[test] +fn the_resolution_is_kept() { + let bytes = ppi::with_png_resolution(&png(DynamicImage::ImageRgba8(framed())), (300.0, 150.0)); + let (mut s, id) = open(&bytes); + s.execute(C, &json!({})).unwrap(); + let (x, y) = ppi::resolution(&bytes_of(&s, id)).expect("a resolution"); + assert_eq!((x.round(), y.round()), (300.0, 150.0)); +} + +#[test] +fn a_cmyk_image_is_refused() { + let inks = vectorcraft_doc::cmyk::Inks::new(4, 2, [0, 0, 0, 0].repeat(8)).unwrap(); + let tiff = ImageBlob::cmyk_tiff(&inks).unwrap(); + let (mut s, id) = open(&tiff.bytes); + let before = (image_of(&s, id), undo_len(&s)); + let e = s.execute(C, &json!({})).unwrap_err(); + assert!(e.to_string().contains("a CMYK image has no transparency to set"), "{e}"); + assert_eq!((image_of(&s, id), undo_len(&s)), before); +} + +/// A linked image of `blob`, `width` × `height` pixels as the object records it, beside the open +/// document's image. +fn place_linked(s: &mut Session, key: &str, blob: ImageBlob, (width, height): (u32, u32)) -> NodeId { + s.edit("Place", |d, sel| { + d.images.insert(key.into(), blob); + let id = d.alloc_id(); + let im = ImageObject { + key: key.into(), + width, + height, + xf: Default::default(), + link: Some(LinkInfo::new("/nowhere/art.png")), + placement: Default::default(), + pixels: None, + }; + d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?; + sel.set([id]); + Ok(id) + }) + .unwrap() +} + +#[test] +fn a_linked_image_is_embedded_and_one_that_shows_its_preview_is_refused() { + let bytes = png(DynamicImage::ImageRgba8(framed())); + let (mut s, _) = open(&bytes); + let linked = place_linked(&mut s, "linked", ImageBlob::png(bytes.clone()), (12, 10)); + let clip = vectorcraft_doc::links::PlacementOptions { clip: true, ..Default::default() }; + s.edit("Placement", |d, _| { + if let Some(NodeKind::Image(im)) = d.node_mut(linked).map(|n| &mut n.kind) { + im.placement = clip; + } + Ok(()) + }) + .unwrap(); + let r = s.execute(C, &json!({"ids": [linked.0, linked.0]})).unwrap(); + assert_eq!(r, json!({"changed": 1, "ids": [linked.0], "cleared": 12 * 10 - 6 * 4}), "an image named twice changes once"); + let im = image_of(&s, linked); + assert_eq!((im.link, alpha(&pixels(&s, linked), 0, 0)), (None, 0), "embedded, with its background cleared"); + assert_eq!(im.placement, clip, "its Placement Options kept, as Embed keeps them"); + // Pixels the file wasn't read for: the preview, as an image whose file is missing shows it. + let preview = Arc::new(bytes); + let missing = place_linked(&mut s, "missing", ImageBlob { mime: "image/png".into(), bytes: preview.clone(), proxy: Some(preview) }, (12, 10)); + let before = (image_of(&s, missing), undo_len(&s)); + let e = s.execute(C, &json!({})).unwrap_err(); + assert!(e.to_string().contains("the linked image shows its preview: embed or relink it first"), "{e}"); + assert_eq!((image_of(&s, missing), undo_len(&s)), before); +} + +/// An object whose pixels were read at another size than it records keeps its place and size. +#[test] +fn an_image_read_at_another_size_keeps_its_place_and_size() { + let bytes = png(DynamicImage::ImageRgba8(framed())); + let (mut s, _) = open(&bytes); + let id = place_linked(&mut s, "linked", ImageBlob::png(bytes), (24, 20)); + s.execute(C, &json!({"ids": [id.0]})).unwrap(); + let im = image_of(&s, id); + assert_eq!((im.width, im.height, im.xf), (12, 10, Affine::scale_non_uniform(2.0, 2.0))); +} + +#[test] +fn bad_params_are_refused_and_change_nothing() { + let (mut s, id) = open(&png(DynamicImage::ImageRgba8(framed()))); + let rect = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + let before = (image_of(&s, id), undo_len(&s)); + // With `ids`, the command is available when one of them is an image. + let not_image = format!("object {rect} is not an image"); + let cases: [(Value, &str); 18] = [ + (json!({"background": "sky"}), "`background` must be edges, corners or colors"), + (json!({"background": 5}), "`background` must be a string"), + (json!({"match": "rgb"}), "`match` must be distance or channels"), + (json!({"match": true}), "`match` must be a string"), + (json!({"contiguous": "yes"}), "`contiguous` must be true or false"), + (json!({"contiguous": 1}), "`contiguous` must be true or false"), + (json!({"tolerance": 101}), "`tolerance` must be from 0 to 100"), + (json!({"softness": -1}), "`softness` must be from 0 to 100"), + (json!({"tolerance": "5"}), "`tolerance` must be a number"), + (json!({"background": "colors"}), "`colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue"), + (json!({"background": "colors", "colors": []}), "`colors` must list"), + (json!({"background": "colors", "colors": "#12"}), "`colors` must list"), + (json!({"background": "colors", "colors": "#ffffff80"}), "`colors` must list"), + (json!({"colors": ["#ffffff", 7]}), "`colors` must list"), + (json!({"background": "colors", "colors": vec!["#000"; 17]}), "`colors` must list"), + (json!({"background": "colors", "colors": "#000 ".repeat(100_000)}), "`colors` must list"), + (json!({"ids": [id.0, rect]}), ¬_image), + (json!({"ids": [rect]}), "select an image"), + ]; + for (p, msg) in cases { + let e = s.execute(C, &p).unwrap_err().to_string(); + assert!(e.contains(msg), "{p}: {e}"); + } + assert_eq!((image_of(&s, id), undo_len(&s)), before); +} + +#[test] +fn an_image_too_large_to_change_is_refused_before_it_is_decoded() { + let run = |w: u32, h: u32| { + let (mut s, _) = open(&crate::tests_linkspanel::png_header(w, h)); + s.execute(C, &json!({})).unwrap_err().to_string() + }; + // As gray samples, RGBA worked on and kept for the PNG, and the work: 19 bytes a pixel. + let over = run(10_632, 10_631); + assert!(over.contains("an image of 10632 × 10631 pixels is too large to change"), "{over}"); + // Within the allowance it is decoded, and its missing rows can't be read. + let within = run(10_631, 10_631); + assert!(within.contains("the image's pixels can't be read"), "{within}"); +} diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 2dfeb497d..1f64e582a 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -19,12 +19,12 @@ //! shape that a Convert to Shape or Outline Object effect on them gives them, below or above the //! image as its Contents row says; new ones go right below that row. -use std::sync::Arc; +use std::{collections::HashMap, sync::Arc}; use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Paint}; use vectorcraft_doc::appearance::SharedStack; -use vectorcraft_doc::{Appearance, AppearanceItem, CharStyle, FillLayer, Node, NodeKind, ParaStyle, StrokeLayer}; +use vectorcraft_doc::{Appearance, AppearanceItem, CharStyle, Document, FillLayer, Node, NodeKind, ParaStyle, StrokeLayer}; use vectorcraft_geom::Rect; use super::edit::{outermost, roots_of, selected_roots}; @@ -126,7 +126,7 @@ pub fn specs() -> Vec { "Eyedropper", [], None, - "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art); with an object of a dynamic symbol instance selected by Direct Selection and no ids, reverse, append or defaultsOnly, that object takes the source's fill and stroke (solid, none, gradient or pattern) for this instance only, as the Eyedropper Options allow, and a source that is not a path, compound path or type, or options that pick up neither color, are refused. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {ids}", + "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes using effective character dimensions while preserving target transforms) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art); with an object of a dynamic symbol instance selected by Direct Selection and no ids, reverse, append or defaultsOnly, that object takes the source's fill and stroke (solid, none, gradient or pattern) for this instance only, as the Eyedropper Options allow, and a source that is not a path, compound path or type, or options that pick up neither color, are refused. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {ids}", has_doc, copy_from ), @@ -1198,7 +1198,7 @@ fn target_contents(s: &mut Session, p: &Value) -> Result { Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) } -/// Sample enabled attributes, refusing object transparency that a text range cannot store. +/// Sample enabled attributes with transform-aware character dimensions and atomic validation. fn copy_from(s: &mut Session, p: &Value) -> Result { const C: &str = "appearance.copyFrom"; let clicked = id_param(p, "source").ok_or_else(|| bad(C, "missing `source` id"))?; @@ -1270,9 +1270,6 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { None => s.eyedropper_sample.clone().ok_or_else(|| bad(C, "select the object to copy from"))?, }; let picked = Picked::of(&node); - if !reverse && !defaults_only { - s.eyedropper_sample = Some(node); - } // The highlighted characters' type objects and ranges: after Select All in threaded text, all // the text of every frame of the story, except a frame the attributes come from, in place of // the object selection. @@ -1285,6 +1282,15 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { Some((id, start, end)) if targets.contains(&id) => vec![(id, Some((start, end)))], _ => vec![], }; + let target_ids = || targets.iter().copied().filter(|_| ranges.is_empty()).chain(ranges.iter().map(|(id, _)| *id)); + // Prepare every dimensional copy before changing defaults or the remembered sample. A bad + // source or target transform must refuse the entire pick, including a multi-frame range. + let characters = if attrs.character && !append { + let d = &s.doc()?.doc; + copied_character_styles(&node, target_ids().filter_map(|id| d.node(id)))? + } else { + HashMap::new() + }; // Character styles cannot represent object transparency. Refuse before even changing // paint defaults; applying it to the object would also alter the unselected characters. if attrs.appearance.transparency { @@ -1298,11 +1304,33 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { } } } - if !reverse { - picked.to_defaults(s, &attrs.appearance); - } + // The session's bounds check follows the selection. Check text targets reached through + // explicit IDs, a reverse pick, or a highlighted story as well. Paragraph changes can + // move point text even when character copying is disabled. + let text_ids: Vec<_> = if attrs.paragraph && !append && picked.text.is_some() { + let d = &s.doc()?.doc; + target_ids().filter(|id| d.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)))).collect() + } else { + characters.keys().copied().collect() + }; + let check_text_bounds = |d: &Document| { + if !text_ids.is_empty() && !crate::sanity::objects_in_range(d, &text_ids) { + return Err(bad(C, "sampled text would exceed the canvas (coordinates out of range)")); + } + Ok(()) + }; + // Remember the pick only after the document edit has passed its own validation. + let finish = |s: &mut Session, ids: &[NodeId]| { + if !reverse { + if !defaults_only { + s.eyedropper_sample = Some(node); + } + picked.to_defaults(s, &attrs.appearance); + } + json!({ "ids": ids.iter().map(|i| i.0).collect::>() }) + }; if (targets.is_empty() && ranges.is_empty()) || attrs.is_empty() { - return Ok(json!({ "ids": [] })); + return Ok(finish(s, &[])); } if !ranges.is_empty() { let ids: Vec = ranges.iter().map(|(f, _)| *f).collect(); @@ -1312,24 +1340,26 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) }; let (start, end) = range.unwrap_or((0, vectorcraft_text::edit::runs_len(&t.runs))); let placed = picked.placed(Some(t.local_bounds())); - vectorcraft_text::edit::style_range(&mut t.runs, start, end, |st| picked.run(st, &placed, &attrs)); + vectorcraft_text::edit::style_range(&mut t.runs, start, end, |st| Picked::run(st, &placed, &attrs, characters.get(&id))); if let Some((_, para)) = picked.text.as_ref().filter(|_| attrs.paragraph) { t.edit_paras(Some(t.paragraphs_in(start, end)), |p| *p = para.clone()); } super::typecmd::refresh_bounds(t); } + check_text_bounds(d)?; Ok(count_changed(d, &before, &ids)) })?; - return Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })); + return Ok(finish(s, &ids)); } // When every target comes out as it was, the document and its undo history stay as they are. let before = s.doc()?.doc.clone(); super::overprint::edit_counted(s, "Eyedropper", |d| { for id in &targets { if let Some(n) = d.node_mut(*id) { - picked.apply(n, &attrs, append); + picked.apply(n, &attrs, append, characters.get(id)); } } + check_text_bounds(d)?; // The paints the pick wrote, with a weight going with the paint of its stroke, as in `set_item`. `append` adds // only the colors of the visible rows the source has. let (fill, stroke) = (attrs.appearance.fill, attrs.appearance.stroke); @@ -1369,7 +1399,59 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { let object = super::graph::capture_series_object_opacity(d, &objects); Ok(count_changed(d, &before, &targets) + usize::from(kept || painted || object)) })?; - Ok(json!({ "ids": targets.iter().map(|i| i.0).collect::>() })) + Ok(finish(s, &targets)) +} + +/// Read source typography in document space, then compensate for each target's transform. +/// Paint stays in its existing sampling path; character scale never changes object placement. +fn copied_character_styles<'a>(source: &Node, targets: impl Iterator) -> Result> { + const C: &str = "appearance.copyFrom"; + let mut styles = HashMap::new(); + let NodeKind::Text(source) = &source.kind else { return Ok(styles) }; + let Some(run) = source.runs.first() else { return Ok(styles) }; + let mut targets = targets + .filter_map(|n| match &n.kind { + NodeKind::Text(t) => Some((n.id, t)), + _ => None, + }) + .peekable(); + if targets.peek().is_none() { + return Ok(styles); + } + let effective = source.effective_char_style(&run.style).ok_or_else(|| bad(C, "source text transform is collapsed or unrepresentable"))?; + if !copied_dimensions_fit(&run.style, &effective) { + return Err(bad(C, "character dimensions cannot be represented with the source text transform")); + } + for (id, text) in targets { + let scale = text.style_scale().ok_or_else(|| bad(C, "target text transform is collapsed or unrepresentable"))?; + let mut local = effective.clone(); + local.size /= scale.points; + local.leading = local.leading.map(|v| v / scale.points); + local.baseline_shift /= scale.points; + local.tcy_up_down /= scale.points; + local.tcy_left_right /= scale.points; + local.h_scale /= scale.horizontal; + // As with dimensional type commands, reject overflow and underflow before layout. + if !copied_dimensions_fit(&effective, &local) { + return Err(bad(C, "character dimensions cannot be represented with the target text transform")); + } + styles.insert(id, local); + } + Ok(styles) +} + +/// Bound each converted type dimension and refuse loss to zero before layout reads it. +fn copied_dimensions_fit(from: &CharStyle, to: &CharStyle) -> bool { + [ + (from.size, to.size), + (from.leading.unwrap_or(0.0), to.leading.unwrap_or(0.0)), + (from.baseline_shift, to.baseline_shift), + (from.tcy_up_down, to.tcy_up_down), + (from.tcy_left_right, to.tcy_left_right), + (from.h_scale, to.h_scale), + ] + .iter() + .all(|(from, to)| to.is_finite() && to.abs() <= crate::MAX_COORD && (*from == 0.0 || *to != 0.0)) } /// What the Eyedropper picks up from an object. @@ -1422,8 +1504,8 @@ impl Picked { /// Copy character attributes and the enabled paint attributes through the same path for /// whole text objects and highlighted character ranges. - fn run(&self, st: &mut CharStyle, placed: &Appearance, a: &EyedropperAttrs) { - if let Some((cs, _)) = self.text.as_ref().filter(|_| a.character) { + fn run(st: &mut CharStyle, placed: &Appearance, a: &EyedropperAttrs, character: Option<&CharStyle>) { + if let Some(cs) = character { *st = CharStyle { fill: std::mem::take(&mut st.fill), stroke: std::mem::take(&mut st.stroke), @@ -1439,7 +1521,7 @@ impl Picked { /// Apply attributes `a` to `n`. `append` adds the fills and strokes (and effects) on top of its /// stack instead. Type takes the paints on its characters, and character and paragraph /// attributes from type. - fn apply(&self, n: &mut Node, a: &EyedropperAttrs, append: bool) { + fn apply(&self, n: &mut Node, a: &EyedropperAttrs, append: bool, character: Option<&CharStyle>) { let ap = &a.appearance; if ap.transparency { n.opacity = self.opacity; @@ -1456,7 +1538,7 @@ impl Picked { let placed = self.placed(Some(t.local_bounds())); let text = self.text.as_ref(); for r in &mut t.runs { - self.run(&mut r.style, &placed, a); + Self::run(&mut r.style, &placed, a, character); } if let Some((_, para)) = text.filter(|_| a.paragraph) { // The source's first paragraph's attributes, on every paragraph. diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index eaa417f4e..6478cc81b 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -2,7 +2,7 @@ //! save, export and serialize by path or bytes (base64), the format table, `command.batch` and //! `action.batch` (the Actions panel menu's Batch…). //! -//! - `load`: `document.open` (native, legacy, SVG/SVGZ, PDF/.ai/.ait, DXF, raster images). +//! - `load`: `document.open` (native, legacy, SVG/SVGZ, PDF/.ai/.ait, DXF, HP-GL, raster images). //! - `action_batch`: play an action on every file in a folder or every data set. //! - `encode`: one encoder per writable format, with typed options parsed from the params. //! - `svg`: the SVG Options (styling, fonts, images, object ids, artboards…). @@ -205,6 +205,8 @@ mod tests_eps; #[cfg(test)] mod tests_dxfimport; #[cfg(test)] +mod tests_hpgl; +#[cfg(test)] mod tests_metafile; #[cfg(test)] diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 13df0d5eb..4fe2d011d 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -9,7 +9,7 @@ pub fn specs() -> Vec { "Open Document", [], None, - "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page; a PSD file's layers become layers holding their pixels, with their names, visibility, opacity and blend modes, groups as layers of sublayers, layer masks as opacity masks, clipped layers and layers in blend modes VectorCraft doesn't have merged with the layers below) | false (one layer per page, without the art that is off; Save then asks for a name; a PSD file opens as its merged image), hiddenLayers?: false (a PSD file's hidden layers come in hidden instead of being left out), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), legacyNameEncoding?: windows-1252 (default) | macintosh | windows-949 (also euc-kr) | shift_jis | gbk | big5 (the code page names in the editing data of a .ai or an EPS are read in when they aren't UTF-8: layers, objects, artboards, colors, gradients, patterns, graphic styles, swatches, brushes, symbols, variables and data sets, and data sets' text values; warnings list the first three such names as read, and count the rest), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name), importer?: \"plugin.\" (an importer plug-in's format id, see document.formats: it opens the file, which must have an extension it lists, also one a built-in format reads), params?: {…} (the importer's own parameters, see plugin.info)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. A file that no built-in format reads, by its content or its extension, opens with the installed importer plug-in that lists its extension (the one whose id sorts first, when several do): it is given the file, at most 128 MiB, and its objects become a new document whose artboard is their bounds; Save then asks for a name, and the warnings are the importer's. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)", + "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page; a PSD file's layers become layers holding their pixels, with their names, visibility, opacity and blend modes, groups as layers of sublayers, layer masks as opacity masks, clipped layers and layers in blend modes VectorCraft doesn't have merged with the layers below) | false (one layer per page, without the art that is off; Save then asks for a name; a PSD file opens as its merged image), hiddenLayers?: false (a PSD file's hidden layers come in hidden instead of being left out), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), legacyNameEncoding?: windows-1252 (default) | macintosh | windows-949 (also euc-kr) | shift_jis | gbk | big5 (the code page names in the editing data of a .ai or an EPS are read in when they aren't UTF-8: layers, objects, artboards, colors, gradients, patterns, graphic styles, swatches, brushes, symbols, variables and data sets, and data sets' text values; warnings list the first three such names as read, and count the rest), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name), importer?: \"plugin.\" (an importer plug-in's format id, see document.formats: it opens the file, which must have an extension it lists, also one a built-in format reads), params?: {…} (the importer's own parameters, see plugin.info)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), HP-GL plot files .plt/.hpgl/.hgl/.hpg (one artboard round the art; what isn't drawn is listed in warnings), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. A file that no built-in format reads, by its content or its extension, opens with the installed importer plug-in that lists its extension (the one whose id sorts first, when several do): it is given the file, at most 128 MiB, and its objects become a new document whose artboard is their bounds; Save then asks for a name, and the warnings are the importer's. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)", always, load::open ), diff --git a/crates/engine/src/cmd/fileio/formats.rs b/crates/engine/src/cmd/fileio/formats.rs index 78ca6149a..b04104f70 100644 --- a/crates/engine/src/cmd/fileio/formats.rs +++ b/crates/engine/src/cmd/fileio/formats.rs @@ -479,6 +479,8 @@ pub const FORMATS: &[Format] = &[ }, Format { id: "emf", label: "EMF", extensions: &["emf"], mime: "image/emf", read: true, write: true, raster: false, options: metafile::OPTIONS }, Format { id: "wmf", label: "WMF", extensions: &["wmf"], mime: "image/wmf", read: true, write: true, raster: false, options: metafile::OPTIONS }, + // HP-GL/2 plot files, read by `vectorcraft_cad::hpgl` into paths; exports don't write them. + reader("hpgl", "HP-GL", &["plt", "hpgl", "hgl", "hpg"], "application/vnd.hp-hpgl", false), Format { id: "tga", label: "Targa", @@ -552,6 +554,10 @@ pub const OPEN_EXTS: &[&str] = &[ "dxf", "emf", "wmf", + "plt", + "hpgl", + "hgl", + "hpg", "eps", "epsf", "ps", @@ -565,9 +571,10 @@ pub const OPEN_EXTS: &[&str] = &[ /// `.epsf`, `.ps`, Targa, PCX, Pixar, JPEG 2000, HEIC/HEIF or JPEG XL type, nor one for the `.pns`, `.dib` and `.rle` names of /// PNG and BMP files (File › Open and Place still read them all). On Linux the desktop entry's EPS /// type (shared-mime-info's) also covers `.epsf`, so file managers offer VectorCraft for it. +/// The packaging declares no type for HP-GL plot files either. pub const UNASSOCIATED_EXTS: &[&str] = &[ "psd", "pdd", "psb", "epsf", "ps", "pns", "dib", "rle", "tga", "vda", "icb", "vst", "pcx", "pxr", "jpf", "jpx", "jp2", "j2k", "j2c", "jpc", - "heic", "heif", "jxl", + "heic", "heif", "jxl", "plt", "hpgl", "hgl", "hpg", ]; /// The extension that picks each writable format when exporting (the format's first; PNG-8 shares @@ -641,6 +648,10 @@ pub const PLACE_EXTS: &[&str] = &[ "dxf", "emf", "wmf", + "plt", + "hpgl", + "hgl", + "hpg", "eps", "epsf", "ps", diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 5f8b70187..fde1ed273 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -134,6 +134,17 @@ pub fn detect(name: &str, bytes: &[u8]) -> Option<&'static Format> { if clipboard::looks_like_svg(&String::from_utf8_lossy(&bytes[..bytes.len().min(4096)])) { return format("svg"); } + // HP-GL has no signature: a file that starts with plotter instructions, named as one or with an + // extension no other format (text, the formats VectorCraft names as unsupported, and the files + // File › Open takes in other ways included) reads. + let ext = super::extension(name); + let other = by_name.is_some_and(|f| f.id != "hpgl") + || super::TEXT_EXTS.contains(&ext.as_str()) + || super::unsupported(&ext).is_some() + || super::opens_as_other(&ext); + if !other && vectorcraft_cad::hpgl::is_hpgl(bytes) { + return format("hpgl"); + } by_name } @@ -487,6 +498,10 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result let (doc, warnings) = super::metafile::import(bytes)?; (doc, warnings, false) } + "hpgl" => { + let i = vectorcraft_cad::hpgl::import(bytes).map_err(err)?; + (i.document, i.warnings, false) + } "affinity" => { let i = super::affinity::import(&title, bytes)?; preview_only = i.preview_only; diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index 2e42ab94e..1f166797f 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -42,7 +42,7 @@ pub(crate) const OPTIONS: &[FormatOption] = &[ name: "compatibility", ty: "string", default: "\"1.7\"", - description: "PDF version: 1.3 (transparency flattened) | 1.4 | 1.5 | 1.6 | 1.7 | 2.0", + description: "PDF version: 1.3 (transparency flattened) | 1.4 | 1.5 | 1.6 | 1.7 | 2.0 (a pdfA2b file at 1.4 with a page longer than 14,400 pt is written as 1.6, with a UserUnit as at 1.6, and a warning; see document.exportPdf)", }, FormatOption { name: "preserveEditing", @@ -99,7 +99,7 @@ pub(crate) const OPTIONS: &[FormatOption] = &[ name: "longPagesInPoints", ty: "boolean", default: "false", - description: "a page with a side longer than 14,400 pt (200 in) is written at its size in points, without a UserUnit, at every PDF version, in files without a standard (PDF/A files from 1.5 on keep the UserUnit)", + description: "a page with a side longer than 14,400 pt (200 in) is written at its size in points, without a UserUnit, at every PDF version, in files without a standard (PDF/A files keep the UserUnit)", }, FormatOption { name: "createLayers", @@ -128,7 +128,7 @@ pub fn specs() -> Vec { "Export PDF", [], None, - "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset, without the layers the file leaves out; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3; a page with a side longer than 14,400 pt keeps its size with a UserUnit from 1.6 on: 1.5 is then written as 1.6, with 1.5's encryption; PDF/X files, 1.3 and 1.4 write it in points, and 1.3 and 1.4 (pdfX1a and pdfX3 too) refuse a page past 32,767 pt whose size or art needs a real number past 32,767; a page whose short side is 3 pt or longer but would be under 3 units at that UserUnit gets a smaller one that keeps the short side at 3 units; a warning gives each such page's size), longPagesInPoints?: false (true: such a page is written at its size in points, without a UserUnit, at every version, and 1.5 stays 1.5, in files without a standard (PDF/A files from 1.5 on keep the UserUnit); apps that keep to the limit cut it to 200 in), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept; a PDF/X file with no bleed on a side where the document's bleed has one comes back with a warning). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A, pdfX3 and pdfX4 always, whatever profiles says), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out), format?: \"pdf\"} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; a standard with a PDF version it doesn't allow and a password with a standard are refused, and so is a PDF/X file that would still break its standard (transparency the flattener left, RGB in pdfX1a, a font not embedded) or a PDF 1.3 file with transparency left. document.export {format: pdf} takes the same options. A key PDF doesn't take, and an unknown key inside one of the option objects, are refused", + "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset, without the layers the file leaves out; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3; a page with a side longer than 14,400 pt keeps its size with a UserUnit from 1.6 on: 1.5 is then written as 1.6, with 1.5's encryption, and so is 1.4 in pdfA2b files, with a warning; PDF/X files and other 1.3 and 1.4 files write it in points, and 1.3 and 1.4 (pdfX1a and pdfX3 too) refuse a page past 32,767 pt whose size or art needs a real number past 32,767; a page whose short side is 3 pt or longer but would be under 3 units at that UserUnit gets a smaller one that keeps the short side at 3 units; a warning gives each such page's size), longPagesInPoints?: false (true: such a page is written at its size in points, without a UserUnit, at every version, and 1.5 stays 1.5, in files without a standard (PDF/A files keep the UserUnit); apps that keep to the limit cut it to 200 in), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept; a PDF/X file with no bleed on a side where the document's bleed has one comes back with a warning). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A, pdfX3 and pdfX4 always, whatever profiles says), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out), format?: \"pdf\"} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; a standard with a PDF version it doesn't allow and a password with a standard are refused, and so is a PDF/X file that would still break its standard (transparency the flattener left, RGB in pdfX1a, a font not embedded) or a PDF 1.3 file with transparency left. document.export {format: pdf} takes the same options. A key PDF doesn't take, and an unknown key inside one of the option objects, are refused", has_doc, export_pdf ), diff --git a/crates/engine/src/cmd/fileio/tests_hpgl.rs b/crates/engine/src/cmd/fileio/tests_hpgl.rs new file mode 100644 index 000000000..271f7b86e --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_hpgl.rs @@ -0,0 +1,104 @@ +//! HP-GL plotter files through the commands: `document.open` by extension or by content, File › +//! Place as one group of paths, the dialogs' filters and the format list, and files that aren't +//! HP-GL refused with a message. + +use serde_json::json; +use vectorcraft_doc::NodeKind; + +use super::*; + +fn b64(bytes: &[u8]) -> String { + vectorcraft_format::base64_encode(bytes) +} + +/// An inch-wide red square and a black diagonal, with the thinnest pen. +const PLOT: &[u8] = b"IN;SP2;PW0;PA0,0;PD1016,0,1016,1016,0,1016,0,0;PU;SP1;PD1016,1016;PU;"; + +#[test] +fn document_open_reads_a_plot_file() { + let mut s = Session::new(); + let r = s.execute("document.open", &json!({"name": "part.plt", "dataBase64": b64(PLOT)})).unwrap(); + assert_eq!((r["format"].as_str(), r["title"].as_str()), (Some("hpgl"), Some("part.plt")), "{r}"); + let st = s.doc().unwrap(); + let board = st.doc.artboards[0].rect; + assert!((board.width() - 72.25).abs() < 1e-9 && (board.height() - 72.25).abs() < 1e-9, "{board:?}"); + let mut paths = vec![]; + st.doc.walk(|n| { + if let NodeKind::Path { .. } = n.kind { + paths.push(n.clone()); + } + }); + assert_eq!(paths.len(), 2); + // Opened as artwork: Save asks for a name. + assert!(st.path.is_none()); + + // A plot file opens by its content in any of its names, and under a name no format reads. + for name in ["drawing.prn", "PART.HPGL", "part.hgl", "part.hpg"] { + let r = Session::new().execute("document.open", &json!({"name": name, "dataBase64": b64(PLOT)})).unwrap(); + assert_eq!(r["format"].as_str(), Some("hpgl"), "{name}: {r}"); + } +} + +#[test] +fn file_place_places_a_plot_file_as_one_group() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap(); + let p = json!({"name": "part.plt", "dataBase64": b64(PLOT), "thumbnail": 16}); + let info = s.execute("file.place.info", &p).unwrap(); + assert_eq!(info["format"], "hpgl", "{info}"); + assert!((info["width"].as_f64().unwrap() - 72.25).abs() < 1e-9, "{info}"); + let r = s.execute("file.place", &p).unwrap(); + assert_eq!((&r["format"], r["ids"].as_array().map(Vec::len)), (&json!("hpgl"), Some(1)), "{r}"); + let doc = &s.doc().unwrap().doc; + let group = doc.node(vectorcraft_doc::NodeId(r["ids"][0].as_u64().unwrap())).unwrap(); + let NodeKind::Group { children, clip, .. } = &group.kind else { panic!("a group, got {:?}", group.kind) }; + assert_eq!((children.len(), *clip), (2, false), "the art's bounds, not clipped"); +} + +#[test] +fn files_that_are_not_hpgl_are_refused() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 50})).unwrap(); + let before = s.doc().unwrap().doc.artboards[0].rect.size(); + let script = b"set terminal png\nplot sin(x)\n"; + let e = s.execute("document.open", &json!({"name": "graph.plt", "dataBase64": b64(script)})).expect_err("refused"); + assert!(e.to_string().contains("not an HP-GL file"), "{e}"); + assert_eq!(s.doc().unwrap().doc.artboards[0].rect.size(), before); + assert!(s.execute("file.place", &json!({"name": "graph.plt", "dataBase64": b64(script)})).is_err()); + // Text that isn't a plot opens by its name, not as HP-GL. + assert!(detect("notes.txt", b"IN THE BEGINNING").is_none_or(|f| f.id != "hpgl")); + // Only a plot file's own names, and names no other format reads, are read by their content: + // text, other formats' files and the formats named as unsupported (a DWG drawing starts with + // `AC` and digits) aren't, even when they start like a plot. + for name in ["notes.txt", "notes.svg", "picture.tga", "drawing.dxf", "site.dwg", "old.pct"] { + for bytes in [&b"IN"[..], b"in 5;", b"AC1032\0\0", PLOT] { + assert!(detect(name, bytes).is_none_or(|f| f.id != "hpgl"), "{name}: {:?}", String::from_utf8_lossy(bytes)); + } + } + let text = s.execute("file.place", &json!({"name": "notes.txt", "dataBase64": b64(b"IN 5; PA 10;")})).unwrap(); + assert_eq!(text["format"], "txt", "{text}"); +} + +/// A plot file named as one opens when its first instruction is one a plot file seldom starts with; +/// under another name it isn't taken for HP-GL. +#[test] +fn a_plot_file_by_its_name_may_start_with_any_instruction() { + let plot = b"SI0.2,0.3;SP1;PA0,0;PD1016,0;"; + let r = Session::new().execute("document.open", &json!({"name": "part.plt", "dataBase64": b64(plot)})).unwrap(); + assert_eq!(r["format"].as_str(), Some("hpgl"), "{r}"); + assert!(detect("drawing.prn", plot).is_none_or(|f| f.id != "hpgl")); +} + +#[test] +fn the_dialogs_and_the_format_list_name_hpgl() { + let exts = ["plt", "hpgl", "hgl", "hpg"]; + for e in exts { + assert!(OPEN_EXTS.contains(&e) && PLACE_EXTS.contains(&e) && UNASSOCIATED_EXTS.contains(&e), "{e}"); + assert_eq!(format_for_name(&format!("a.{e}")).map(|f| f.id), Some("hpgl"), "{e}"); + } + let filter = ("HP-GL", &exts[..]); + assert!(open_filters().any(|f| f == filter) && place_filters().any(|f| f == filter)); + let r = Session::new().execute("document.formats", &json!({})).unwrap(); + let f = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "hpgl").unwrap().clone(); + assert_eq!((f["read"].as_bool(), f["write"].as_bool(), f["raster"].as_bool()), (Some(true), Some(false), Some(false))); +} diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 020b483f3..a134eb1c5 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -41,7 +41,7 @@ pub fn specs() -> Vec { "Check Links", [], None, - "{ids?: [image or placed document ids] (default: every linked image and placed document)} where each linked file is and whether it changed since it was read → {links: [{name, path, status: ok|modified|missing, ids, found?: path (found away from its path: relative to the document, or by name in its folder), preview: true (showing the low-resolution preview, not the file), refused?: why the path is not followed (another computer, a device or a URL: it counts as missing)}], missing, modified}", + "{ids?: [image or placed document ids] (default: every linked image and placed document)} where each linked file is and whether it changed since it was read → {links: [{name, path?, status: ok|modified|missing, ids, found?: path (found away from its path: relative to the document, or by name in its folder), preview: true (showing the low-resolution preview, not the file), refused?: why the path is not followed (another computer, a device or a URL: it counts as missing)}], missing, modified}; under automation read roots, path and found are left out when the file they name is outside the roots", has_doc, check ), @@ -77,7 +77,7 @@ pub fn specs() -> Vec { "Links", [], None, - "{show?: all (default)|missing|modified|embedded, sort?: name|kind (file format)|status (missing, modified, ok, embedded); default: stacking order; top first} every image object and placed document in the layers, as the Links panel lists them → {links: [{id, name, linked, status: ok|modified|missing|embedded, format, pixelWidth, pixelHeight, path?, found?: path (found away from its path), page?, preview?: true (showing the saved preview), note?: said of an image past the decode allowance (shown at reduced size, or too large to draw), document?: true (a placed document: pageWidth and pageHeight in pt instead of pixels)}], missing, modified, embedded}", + "{show?: all (default)|missing|modified|embedded, sort?: name|kind (file format)|status (missing, modified, ok, embedded); default: stacking order; top first} every image object and placed document in the layers, as the Links panel lists them → {links: [{id, name, linked, status: ok|modified|missing|embedded, format, pixelWidth, pixelHeight, path?, found?: path (found away from its path), page?, preview?: true (showing the saved preview), note?: said of an image past the decode allowance (shown at reduced size, or too large to draw), document?: true (a placed document: pageWidth and pageHeight in pt instead of pixels)}], missing, modified, embedded}; under automation read roots, path and found are left out when the file they name is outside the roots", has_doc, list ), @@ -113,7 +113,7 @@ pub fn specs() -> Vec { "Link Info", [], None, - "{id?} an image's or placed document's Link Info (default: the one selected) → image.info's fields (id, name, linked, link, colorMode, pixelWidth, pixelHeight, width, height) and status: ok|modified|missing|embedded, format, ppi: [x, y] (the file's), effectivePpi: [x, y] (at its placed size), scale: [x%, y%] (of its 100% size), rotation (degrees, counter-clockwise), placement: {preserve, align, clip}; linked images also fileName, location (its folder), page?, fileSize? (bytes), modified?, created? (ms since the Unix epoch), refused? (why the path is not followed); a placed document has document: true, pageWidth and pageHeight (pt) instead of pixels, ppi and colorMode, and its scale is of its artboard's or page's 100% size", + "{id?} an image's or placed document's Link Info (default: the one selected) → image.info's fields (id, name, linked, link, colorMode, pixelWidth, pixelHeight, width, height) and status: ok|modified|missing|embedded, format, ppi: [x, y] (the file's), effectivePpi: [x, y] (at its placed size), scale: [x%, y%] (of its 100% size), rotation (degrees, counter-clockwise), placement: {preserve, align, clip}; linked images also fileName, location (its folder), page?, fileSize? (bytes), modified?, created? (ms since the Unix epoch), refused? (why the path is not followed); a placed document has document: true, pageWidth and pageHeight (pt) instead of pixels, ppi and colorMode, and its scale is of its artboard's or page's 100% size; under automation read roots, a link whose file is outside the roots has no link, path or location, and fileSize and modified come only from the file", has_doc, info ), @@ -882,17 +882,35 @@ fn install(d: &mut Document, ids: &[NodeId], (link, img): FileRead, force: Optio } /// Groups (of the document saved at `doc_path`) as `{name, path, ids}` rows (those with objects in -/// the layers), with `refused`: why the path isn't followed, when it isn't. +/// the layers), with `refused`: why the path isn't followed, when it isn't. `path` is left out +/// where [`path_shown`] says so. fn rows<'a>(gs: impl Iterator, doc_path: Option<&str>) -> Vec { gs.filter(|g| !g.ids.is_empty()) .map(|g| { - let mut row = json!({ "name": g.link.name(), "path": g.link.path, "ids": ids_json(&g.ids) }); + let mut row = json!({ "name": g.link.name(), "ids": ids_json(&g.ids) }); + note_path(&mut row, "path", &g.link.path, doc_path); note_refused(&mut row, &g.link, doc_path); row }) .collect() } +/// May a reply to the caller asking now carry the link path `path` (of the document saved at +/// `doc_path`)? Always without read roots in force; with them, only when the file it names (a +/// relative path taken from the document's folder) lies inside them. +fn path_shown(path: &str, doc_path: Option<&str>) -> bool { + crate::file_access::current().is_none() || fileio::document_path(path, doc_path).is_ok_and(|p| crate::file_access::check_read(&p).is_ok()) +} + +/// `row[key] = path` when [`path_shown`] says so; otherwise `row` has no `key`. +fn note_path(row: &mut Value, key: &str, path: &str, doc_path: Option<&str>) { + if path_shown(path, doc_path) { + row[key] = json!(path); + } else if let Some(o) = row.as_object_mut() { + o.remove(key); + } +} + /// `row["refused"]`: why the path of `link` isn't followed, when it isn't ([`refused`]). fn note_refused(row: &mut Value, link: &LinkInfo, doc_path: Option<&str>) { if let Some(r) = refused(link, doc_path) { @@ -1188,10 +1206,12 @@ fn check(s: &mut Session, p: &Value) -> Result { 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, LinkBase::of(st).doc); + let doc_path = LinkBase::of(st).doc; + let mut row = json!({ "name": g.link.name(), "status": status.as_str(), "ids": ids_json(&g.ids) }); + note_path(&mut row, "path", &g.link.path, doc_path); + note_refused(&mut row, &g.link, doc_path); if let Some(f) = found.filter(|f| f.path != g.link.path) { - row["found"] = json!(f.path); + note_path(&mut row, "found", &f.path, doc_path); } if stale(&st.doc, g) { row["preview"] = json!(true); @@ -1663,14 +1683,15 @@ fn list(s: &mut Session, p: &Value) -> Result { o.insert("linked".into(), json!(link.is_some())); o.insert("status".into(), json!(status)); } + let doc_path = LinkBase::of(st).doc; if let Some(l) = link { - row["path"] = json!(l.path); + note_path(&mut row, "path", &l.path, doc_path); if let Some(page) = l.page { row["page"] = json!(page); } } if let Some(f) = found { - row["found"] = json!(f); + note_path(&mut row, "found", f, doc_path); } if preview { row["preview"] = json!(true); @@ -1811,13 +1832,17 @@ fn placement_json(o: PlacementOptions) -> Value { /// Link Info's fields of the linked file `l` (of object `id` of `st`) into `out`. fn file_fields(st: &crate::DocState, id: NodeId, l: &LinkInfo, out: &mut Value) { + // Whether the path is shown is judged for the caller asking, before the opener's roots apply. + let shown = path_shown(&l.path, LinkBase::of(st).doc); // A confined caller reads within its own roots; anyone else within what the caller that // opened the document may read (never wider than the caller asking now). let roots = crate::file_access::current().or_else(|| st.opened_by.clone()); - crate::file_access::confine(roots.as_ref(), || file_fields_here(st, id, l, out)); + crate::file_access::confine(roots.as_ref(), || file_fields_here(st, id, l, shown, out)); } -fn file_fields_here(st: &crate::DocState, id: NodeId, l: &LinkInfo, out: &mut Value) { +/// [`file_fields`]; without `shown`, the link's path (`path`, `link`) and folder (`location`) +/// and the size and date the document keeps of its file are left out. +fn file_fields_here(st: &crate::DocState, id: NodeId, l: &LinkInfo, shown: bool, out: &mut Value) { 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; @@ -1825,13 +1850,20 @@ fn file_fields_here(st: &crate::DocState, id: NodeId, l: &LinkInfo, out: &mut Va let stamp = path.as_deref().and_then(file_stamp); out["status"] = json!(status.as_str()); note_refused(out, l, doc_path); - out["path"] = json!(l.path); + if shown { + out["path"] = json!(l.path); + out["location"] = json!(l.folder()); + } else if let Some(o) = out.as_object_mut() { + for k in ["path", "link", "location"] { + o.remove(k); + } + } out["fileName"] = json!(l.name()); - out["location"] = json!(l.folder()); + let kept = |v: Option| v.filter(|_| shown); for (k, v) in [ ("page", l.page.map(u64::from)), - ("fileSize", stamp.map(|s| s.0).or(l.size)), - ("modified", stamp.and_then(|s| s.1).or(l.modified)), + ("fileSize", stamp.map(|s| s.0).or(kept(l.size))), + ("modified", stamp.and_then(|s| s.1).or(kept(l.modified))), ("created", path.as_deref().and_then(file_created)), ] { if let Some(v) = v { diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index 8ea016db6..b3992ac2a 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -871,7 +871,7 @@ fn rasterize(s: &mut Session, p: &Value) -> Result { Ok(out) } -fn has_image(s: &Session) -> std::result::Result<(), String> { +pub(crate) fn has_image(s: &Session) -> std::result::Result<(), String> { has_selection(s)?; let st = s.active().ok_or("no document open")?; if st.selection.objects.iter().any(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Image(_)))) { diff --git a/crates/engine/src/cmd/place.rs b/crates/engine/src/cmd/place.rs index a59e7267e..a6e7f6728 100644 --- a/crates/engine/src/cmd/place.rs +++ b/crates/engine/src/cmd/place.rs @@ -5,7 +5,7 @@ //! the file declares ([`fileio::ppi`]; 72 ppi when it declares none), linked to its file with //! `link` ([`super::links`]). //! - An SVG becomes one group of its art; so does a DXF drawing, read with the DXF options -//! (`dxf`, [`fileio::dxfimport`]). +//! (`dxf`, [`fileio::dxfimport`]), and an HP-GL plot file (`vectorcraft_cad::hpgl`). //! - An EMF or WMF picture becomes one group, clipped to its frame (`crop: "crop"`) or bounded by //! its art (`crop: "bounding"`). //! - An EPS file (or a PostScript .ai) becomes one group, clipped to its bounding box or bounded by @@ -69,7 +69,7 @@ pub fn specs() -> Vec { "Place…", ["File"], Some("Cmd+Shift+P"), - "{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect; refused while an object of a symbol instance's art is selected), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed", + "{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect; refused while an object of a symbol instance's art is selected), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, HP-GL (.plt, .hpgl, .hgl, .hpg), PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed", has_doc, place ), @@ -264,7 +264,7 @@ fn load(p: &Value, cmd: &str, board: Option, link_pdf: bool) -> Result Result { } // 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. + // held back. A failed write of that module is only logged: the store then holds the new module, + // which the grant doesn't cover. A write that reports a failure may still have put the old + // module back, so the store is read again to tell which module it holds. 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); + let holds_new = |s: &Session| s.stored_plugin_bytes(plugin.id()).is_ok_and(|held| held.as_deref() == Some(&bytes[..])); if m.asks_permissions() { let g = Grant { id: m.id.clone(), hash: plugin.hash().to_string(), permissions: wanted }; registry::grant(g.clone()); if stored && !s.store_grant(&m.id, Some(g)) { - s.put_back_plugin(plugin.id(), previous.ok().flatten().as_deref()); - stored = false; + stored = !s.put_back_plugin(plugin.id(), previous.ok().flatten().as_deref()) && holds_new(s); } } else { // This module takes the id over, so nothing the user approved for the id applies any more. @@ -764,7 +765,7 @@ fn install(s: &mut Session, p: &Value) -> Result { // stays; one that can't be opened or read may approve the old bytes once it reads again. if stored && !s.store_grant(&m.id, None) && s.holds_plugin_grant(&m.id) { let back = previous.as_ref().is_ok_and(|held| s.put_back_plugin(plugin.id(), held.as_deref())); - stored = !back; + stored = !back && holds_new(s); if !back || matches!(previous, Ok(None)) { s.ignore_stored_grant(&m.id); } diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs index 416a44adb..51470e742 100644 --- a/crates/engine/src/cmd/plugin_call/reviewed.rs +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -32,6 +32,7 @@ pub(super) const ALLOWED: &[&str] = &[ "addon.color.strokeToFill", "addon.color.swatchLegend", "addon.contour.set", + "addon.image.removeBackground", "addon.pathtools.addPoints", "addon.pathtools.close", "addon.pathtools.corner", diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 099f99abd..f1f188f00 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -282,6 +282,9 @@ pub struct Session { pub(crate) in_tool_actions: bool, /// While [`Session::preview`] runs its command: its edits are the interaction's own. previewing: bool, + /// The images Remove Background decoded during the interaction's preview steps, emptied when + /// it ends ([`cmd::DecodedImages`]). + pub(crate) decoded_images: cmd::DecodedImages, /// Envelope Options with no envelope selected: the options and fidelity new envelopes get /// (`None`: the reference app's defaults, fidelity 50); not saved. pub(crate) envelope_defaults: Option<(vectorcraft_doc::live::EnvelopeOptions, f64)>, diff --git a/crates/engine/src/script/files.rs b/crates/engine/src/script/files.rs index 7c3b829a8..ec848a973 100644 --- a/crates/engine/src/script/files.rs +++ b/crates/engine/src/script/files.rs @@ -189,11 +189,15 @@ fn home_folder() -> String { } /// The folders `Folder.temp`, `Folder.desktop` and the others name, `null` where there is none. +/// On the web every one is `null`, and no platform folder is looked up. fn locations(home: &str) -> Data { + if cfg!(target_arch = "wasm32") { + return Data::Object(PLACES.iter().map(|k| (k.to_string(), Data::Null)).collect()); + } let env = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty()); let join = |a: &str, b: &str| Path::new(a).join(b).to_string_lossy().into_owned(); let exe = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_string_lossy().into_owned())); - let temp = std::env::temp_dir().to_string_lossy().into_owned(); + let temp = temp_folder().to_string_lossy().into_owned(); let (user_data, app_data, common, system, trash) = if WINDOWS { (env("APPDATA"), env("PROGRAMDATA"), env("CommonProgramFiles"), env("SystemRoot"), None) } else if cfg!(target_os = "macos") { @@ -209,7 +213,6 @@ fn locations(home: &str) -> Data { (Some(data.clone()), Some("/usr/share".to_string()), Some("/usr/share".to_string()), Some("/".to_string()), Some(join(&data, "Trash"))) }; let s = |v: Option| v.map_or(Data::Null, Data::String); - let web = cfg!(target_arch = "wasm32"); let places = vec![ ("appData", app_data), ("appPackage", exe.clone()), @@ -222,7 +225,15 @@ fn locations(home: &str) -> Data { ("trash", trash), ("userData", user_data), ]; - Data::Object(places.into_iter().map(|(k, v)| (k.to_string(), if web { Data::Null } else { s(v) })).collect()) + Data::Object(places.into_iter().map(|(k, v)| (k.to_string(), s(v))).collect()) +} + +/// The names [`locations`] gives. +const PLACES: [&str; 10] = ["appData", "appPackage", "commonFiles", "desktop", "myDocuments", "startup", "system", "temp", "trash", "userData"]; + +/// The folder for temporary files: the platform's, or on the web the root, where nothing is read. +fn temp_folder() -> std::path::PathBuf { + if cfg!(target_arch = "wasm32") { std::path::PathBuf::from("/") } else { std::env::temp_dir() } } /// The start of an absolute path. @@ -349,12 +360,12 @@ impl Pathname { } } -/// `s` with every character but letters, digits and `-_.!~*'()` written as `%xx` escapes of its +/// `s` with every character but letters, digits and `/-_.!~*'()` written as `%xx` escapes of its /// UTF-8 bytes (RFC 2396). fn encode(s: &str) -> String { let mut out = String::with_capacity(s.len()); for b in s.bytes() { - if b.is_ascii_alphanumeric() || b"-_.!~*'()".contains(&b) { + if b.is_ascii_alphanumeric() || b"/-_.!~*'()".contains(&b) { out.push(b as char); } else { out.push_str(&format!("%{b:02X}")); @@ -842,7 +853,23 @@ fn temp_name(host: &Host, _: &[Data]) -> Result { let mut disk = host.disk.borrow_mut(); disk.temps += 1; let name = format!("vc{:05}.tmp", disk.temps); - Ok(Data::String(std::env::temp_dir().join(name).to_string_lossy().into_owned())) + Ok(Data::String(temp_folder().join(name).to_string_lossy().into_owned())) +} + +fn locations_of(host: &Host, _: &[Data]) -> Result { + Ok(locations(&host.disk.borrow().home)) +} + +fn encode_text(_: &Host, a: &[Data]) -> Result { + Ok(Data::String(encode(&arg_text(a, 0)))) +} + +fn decode_text(_: &Host, a: &[Data]) -> Result { + Ok(Data::String(decode(&arg_text(a, 0)))) +} + +fn encoding_available(_: &Host, a: &[Data]) -> Result { + Ok(Data::Bool(Encoding::parse(&arg_text(a, 0)).is_some())) } /// The folder `Folder.current` starts at in the run `host` serves: the script's own folder. @@ -851,10 +878,6 @@ fn start_folder(host: &Host, _: &[Data]) -> Result { Ok(Data::String(disk.pathname(&disk.start, &disk.start, false).fs())) } -fn locations_of(host: &Host, _: &[Data]) -> Result { - Ok(locations(&host.disk.borrow().home)) -} - /// Adds `File` and `Folder` to the realm, their host functions acting for the run `link` holds. pub(super) fn install(realm: &mut Realm, link: &super::Link) -> Result<(), RunError> { let unchecked = |f: fn(&Host, &[Data]) -> Result| { @@ -871,7 +894,6 @@ pub(super) fn install(realm: &mut Realm, link: &super::Link) -> Result<(), RunEr ("noPicker".into(), value(NO_PICKER)), ("badMode".into(), value(BAD_MODE)), ]); - let pure = |f: fn(&str) -> Data| HostItem::Function(Rc::new(move |a| Ok(f(&arg_text(&a, 0))))); let system = if WINDOWS { "Windows" } else if cfg!(target_os = "macos") { @@ -884,10 +906,10 @@ pub(super) fn install(realm: &mut Realm, link: &super::Link) -> Result<(), RunEr ("system".into(), value(system)), ("lineFeed".into(), value(if WINDOWS { "Windows" } else { "Unix" })), ("current".into(), unchecked(start_folder)), - ("locations".into(), unchecked(locations_of)), - ("encode".into(), pure(|s| Data::String(encode(s)))), - ("decode".into(), pure(|s| Data::String(decode(s)))), - ("encodingAvailable".into(), pure(|s| Data::Bool(Encoding::parse(s).is_some()))), + ("locations".into(), call(link, locations_of)), + ("encode".into(), call(link, encode_text)), + ("decode".into(), call(link, decode_text)), + ("encodingAvailable".into(), call(link, encoding_available)), ("parse".into(), call(link, parse)), ("relative".into(), call(link, relative)), ("tempName".into(), call(link, temp_name)), @@ -955,7 +977,7 @@ mod tests { #[test] fn uri_escapes_are_utf8_both_ways() { assert_eq!(encode("caf\u{e9} 1"), "caf%C3%A9%201"); - assert_eq!(encode("a-_.!~*'()/b"), "a-_.!~*'()%2Fb"); + assert_eq!(encode("a-_.!~*'()/b"), "a-_.!~*'()/b"); assert_eq!(decode("caf%C3%A9%20x"), "caf\u{e9} x"); assert_eq!(decode("100%"), "100%"); assert_eq!(decode("%E9"), "%E9", "a lone Latin-1 escape is not UTF-8"); diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index d39979524..83a036775 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -115,6 +115,7 @@ impl Session { recovery: Default::default(), in_tool_actions: false, previewing: false, + decoded_images: Default::default(), envelope_defaults: None, liquify_stroke: None, plane_widget_press: false, diff --git a/crates/engine/src/session/edit.rs b/crates/engine/src/session/edit.rs index 1762c9676..c7d22ab6c 100644 --- a/crates/engine/src/session/edit.rs +++ b/crates/engine/src/session/edit.rs @@ -335,6 +335,7 @@ impl Session { pub fn commit_interaction(&mut self) -> Result<()> { self.clear_deferred_overlay_cache(); + self.decoded_images.empty(); if let Some(paint) = self.active().and_then(|st| st.interaction.as_ref()).and_then(|it| it.preview_paint.clone()) { self.paint = paint; } @@ -414,6 +415,7 @@ impl Session { pub fn cancel_interaction(&mut self) -> Result<()> { self.clear_deferred_overlay_cache(); + self.decoded_images.empty(); self.pending_paint = None; if let Some(paint) = self .active() diff --git a/crates/engine/src/store.rs b/crates/engine/src/store.rs index ce3da2435..134033ea6 100644 --- a/crates/engine/src/store.rs +++ b/crates/engine/src/store.rs @@ -193,6 +193,9 @@ thread_local! { /// Tests: removing [`GRANTS_FILE`] fails on this thread while the file still reads, as when /// another program holds it open. pub(crate) static GRANTS_DELETE_FAILS: std::cell::Cell = const { std::cell::Cell::new(false) }; + /// Tests: [`Session::put_back_plugin`] writes the old module back on this thread and then + /// reports a failure, as when the last flush of a copy fails. + pub(crate) static PUT_BACK_FAILS_AFTER_WRITE: std::cell::Cell = const { std::cell::Cell::new(false) }; /// Tests: moving a plug-in's settings and cache back from `.removed` ([`move_back`]) /// fails on this thread, as when another program holds a file in the folder. pub(crate) static MOVE_BACK_FAILS: std::cell::Cell = const { std::cell::Cell::new(false) }; @@ -515,16 +518,21 @@ impl Session { /// 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. → whether the - /// store now holds `previous`. + /// write reported success; a write that reports a failure may still have put `previous` back. pub(crate) fn put_back_plugin(&self, id: &str, previous: Option<&[u8]>) -> bool { #[cfg(test)] if PUT_BACK_FAILS.get() { return false; } - match previous { + let back = match previous { Some(bytes) => self.store_plugin(id, bytes), None => self.unstore_plugin(id), + }; + #[cfg(test)] + if PUT_BACK_FAILS_AFTER_WRITE.get() { + return false; } + back } /// The file the plug-in `id` is stored in, spelled as [`Session::load_stored_plugins`] names it diff --git a/crates/engine/src/tests/constrain_tools.rs b/crates/engine/src/tests/constrain_tools.rs index a93965eb0..41acc5417 100644 --- a/crates/engine/src/tests/constrain_tools.rs +++ b/crates/engine/src/tests/constrain_tools.rs @@ -115,6 +115,9 @@ fn constrain_angle_draws_boxes_along_the_turned_axes() { let mut s = new_doc(); set_pref(&mut s, "constrainAngle", json!(90)); for tool in ["rectangle", "ellipse"] { + // The shape drawn before stays selected, and a press on its box's corner handle would + // scale it instead of drawing. + s.execute("select.none", &json!({})).unwrap(); s.select_tool(tool, ViewInfo::default()).unwrap(); // 60 right and 20 down on the page: along axes turned 90°, 20 wide and 60 high. gesture(&mut s, Mods::default(), &[(Down, 100.0, 100.0), (Drag, 130.0, 110.0), (Drag, 160.0, 120.0), (Up, 160.0, 120.0)]); diff --git a/crates/engine/src/tests/eyedropper.rs b/crates/engine/src/tests/eyedropper.rs index f8363fe93..b3a9012e0 100644 --- a/crates/engine/src/tests/eyedropper.rs +++ b/crates/engine/src/tests/eyedropper.rs @@ -215,6 +215,291 @@ fn type_picks_up_font_and_size() { assert_eq!((st.size, justify, st.fill.color().unwrap().to_hex()), (12.0, preserved_alignment, "#00ff00".into())); } +/// Sample document-space typography between text objects with different local transforms. +#[test] +fn scaled_type_pick_matches_effective_character_dimensions() { + for source_area in [false, true] { + for target_area in [false, true] { + let mut s = session(); + let create = |s: &mut Session, area: bool, x: f64| { + let mut p = json!({"x": x, "y": 100, "text": "Sample", "size": 12}); + if area { + p["area"] = json!({"width": 160, "height": 80}); + } + id_of(s.execute("text.create", &p).unwrap()) + }; + let src = create(&mut s, source_area, 100.0); + s.execute("object.transform", &json!({"matrix": [3, 0, 0, 2, 0, 0]})).unwrap(); + let dst = create(&mut s, target_area, 300.0); + s.execute("object.transform", &json!({"matrix": [0.5, 0, 0, 1.5, 0, 0]})).unwrap(); + let before = node(&s, dst); + let source = node(&s, src); + let history = undo_len(&s); + s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap(); + let after = node(&s, dst); + let NodeKind::Text(t) = &after.kind else { panic!("not text") }; + let NodeKind::Text(old) = &before.kind else { panic!("not text") }; + let effective = t.effective_char_style(&t.first_style()).unwrap(); + assert!((effective.size - 24.0).abs() < 1e-8, "sampled font size: {}", effective.size); + assert!((effective.h_scale - 150.0).abs() < 1e-8, "sampled horizontal scale: {}", effective.h_scale); + assert_eq!(t.xf, old.xf, "sampling keeps the target's transform"); + assert_eq!(node(&s, src), source); + assert_eq!(undo_len(&s), history + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, dst), before); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(node(&s, dst), after); + } + } +} + +/// A text object snapshot, including the local attributes and retained affine transform. +fn sampled_type(s: &Session, id: NodeId) -> vectorcraft_doc::TextObject { + let NodeKind::Text(t) = node(s, id).kind else { panic!("not text") }; + *t +} + +/// Compare the dimensional character attributes that text controls display in document units. +fn assert_sampled_dimensions(actual: &vectorcraft_doc::CharStyle, expected: &vectorcraft_doc::CharStyle) { + assert_eq!(actual.leading.is_some(), expected.leading.is_some()); + for (a, e) in [ + (actual.size, expected.size), + (actual.h_scale, expected.h_scale), + (actual.v_scale, expected.v_scale), + (actual.leading.unwrap_or(0.0), expected.leading.unwrap_or(0.0)), + (actual.baseline_shift, expected.baseline_shift), + (actual.tcy_up_down, expected.tcy_up_down), + (actual.tcy_left_right, expected.tcy_left_right), + ] { + assert!((a - e).abs() < 1e-8, "sampled dimension: {a} != {e}"); + } +} + +/// Rotation, shear, reflection and grouping stay on the objects while typography is copied. +#[test] +fn scaled_type_pick_preserves_affines_and_document_dimensions() { + for grouped in [false, true] { + let mut s = session(); + let src = id_of(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Source", "size": 12})).unwrap()); + s.execute("text.setStyle", &json!({"leading": 18})).unwrap(); + s.execute("text.setFormat", &json!({"hScale": 80, "vScale": 125, "baselineShift": 3, "tcyUpDown": 2, "tcyLeftRight": -1})).unwrap(); + if grouped { + s.execute("object.group", &json!({})).unwrap(); + } + s.execute("object.transform", &json!({"matrix": [0, -3, -2, 1, 0, 0]})).unwrap(); + let src_text = sampled_type(&s, src); + let expected = src_text.effective_char_style(&src_text.first_style()).unwrap(); + let dst = id_of(s.execute("text.create", &json!({"x": 300, "y": 100, "text": "Target", "size": 20})).unwrap()); + if grouped { + s.execute("object.group", &json!({})).unwrap(); + } + s.execute("object.transform", &json!({"matrix": [-1, 0.5, 1, 2, 0, 0]})).unwrap(); + let before = sampled_type(&s, dst); + s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap(); + let after = sampled_type(&s, dst); + assert_sampled_dimensions(&after.effective_char_style(&after.first_style()).unwrap(), &expected); + assert_eq!(after.xf, before.xf); + assert_eq!(sampled_type(&s, src), src_text); + let bytes = vectorcraft_format::save_file(&s.doc().unwrap().doc); + let reopened = vectorcraft_format::load(&bytes).unwrap(); + let NodeKind::Text(t) = &reopened.node(dst).unwrap().kind else { panic!("not text") }; + assert_sampled_dimensions(&t.effective_char_style(&t.first_style()).unwrap(), &expected); + } +} + +/// A transformed target compensates only the highlighted characters, retaining the others. +#[test] +fn scaled_type_pick_changes_only_the_highlighted_range() { + let mut s = session(); + let src = id_of(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Source", "size": 12})).unwrap()); + s.execute("object.transform", &json!({"matrix": [3, 0, 0, 2, 0, 0]})).unwrap(); + let source = sampled_type(&s, src); + let expected = source.effective_char_style(&source.first_style()).unwrap(); + let dst = id_of(s.execute("text.create", &json!({"x": 300, "y": 100, "text": "Sample", "size": 10})).unwrap()); + s.execute("object.transform", &json!({"matrix": [0.5, 0, 0, 3, 0, 0]})).unwrap(); + let before = sampled_type(&s, dst); + s.select_tool("type", ViewInfo::default()).unwrap(); + s.set_tool_option("textSelection", &json!({"id": dst.0, "start": 1, "end": 4})); + s.select_tool("eyedropper", ViewInfo::default()).unwrap(); + let history = undo_len(&s); + s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap(); + let after = sampled_type(&s, dst); + assert_eq!(after.runs.len(), 3); + assert_eq!((&*after.runs[0].text, &*after.runs[1].text, &*after.runs[2].text), ("S", "amp", "le")); + assert_eq!(after.runs[0].style, before.first_style()); + assert_eq!(after.runs[2].style, before.first_style()); + assert_sampled_dimensions(&after.effective_char_style(&after.runs[1].style).unwrap(), &expected); + assert_eq!(after.xf, before.xf); + assert_eq!(undo_len(&s), history + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(sampled_type(&s, dst), before); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(sampled_type(&s, dst), after); +} + +/// Reject unusable source or target transforms before any part of a multi-object pick changes. +#[test] +fn scaled_type_pick_rejects_unrepresentable_dimensions_atomically() { + for (bad_source, factor) in [(true, 0.0), (false, 0.0), (false, 1e-306), (true, f64::from_bits(1))] { + let mut s = session(); + let size = if bad_source && factor > 0.0 { 0.1 } else { 24.0 }; + let src = id_of(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Source", "size": size, "color": "#00ff00"})).unwrap()); + if bad_source { + s.execute("object.transform", &json!({"matrix": [factor, 0, 0, factor, 0, 0]})).unwrap(); + } + let good = id_of(s.execute("text.create", &json!({"x": 300, "y": 100, "text": "Good", "size": 10})).unwrap()); + let other = id_of(s.execute("text.create", &json!({"x": 300, "y": 200, "text": "Other", "size": 12})).unwrap()); + if !bad_source { + s.execute("object.transform", &json!({"matrix": [factor, 0, 0, factor, 0, 0]})).unwrap(); + } + s.execute("select.set", &json!({"ids": [good.0, other.0]})).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + let selection = s.doc().unwrap().selection.clone(); + let paint = s.paint.clone(); + let sample = s.eyedropper_sample.clone(); + let history = undo_len(&s); + let error = s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap_err(); + assert!(error.to_string().contains("transform"), "{error}"); + assert_eq!(s.doc().unwrap().doc, doc); + assert_eq!(s.doc().unwrap().selection, selection); + assert_eq!(s.paint, paint); + assert_eq!(s.eyedropper_sample, sample); + assert_eq!(undo_len(&s), history); + // A paint-only pick does not need a usable text transform. + s.execute("appearance.copyFrom", &json!({"source": src.0, "apply": {"character": false, "paragraph": false}})).unwrap(); + } +} + +/// A failed dimensional pick retains highlighted characters and the previously remembered sample. +#[test] +fn scaled_type_pick_rejection_preserves_the_highlighted_range_and_sample() { + let mut s = session(); + let saved = id_of(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Saved", "size": 18})).unwrap()); + s.execute("select.none", &json!({})).unwrap(); + s.execute("appearance.copyFrom", &json!({"source": saved.0})).unwrap(); + let src = id_of(s.execute("text.create", &json!({"x": 100, "y": 200, "text": "Source", "size": 24, "color": "#00ff00"})).unwrap()); + s.execute("object.transform", &json!({"matrix": [0, 0, 0, 0, 0, 0]})).unwrap(); + let dst = id_of(s.execute("text.create", &json!({"x": 300, "y": 100, "text": "Sample", "size": 10})).unwrap()); + s.select_tool("type", ViewInfo::default()).unwrap(); + s.set_tool_option("textSelection", &json!({"id": dst.0, "start": 1, "end": 4})); + s.select_tool("eyedropper", ViewInfo::default()).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + let selection = s.doc().unwrap().selection.clone(); + let range = s.tool_options(); + let paint = s.paint.clone(); + let sample = s.eyedropper_sample.clone(); + let history = undo_len(&s); + assert!(s.execute("appearance.copyFrom", &json!({"source": src.0})).is_err()); + assert_eq!(s.doc().unwrap().doc, doc); + assert_eq!(s.doc().unwrap().selection, selection); + assert_eq!(s.tool_options(), range); + assert_eq!(s.paint, paint); + assert_eq!(s.eyedropper_sample, sample); + assert_eq!(undo_len(&s), history); + // Alt-click can still apply the last successful sample after the rejected pick. + s.execute("select.none", &json!({})).unwrap(); + s.execute("appearance.copyFrom", &json!({"source": dst.0, "reverse": true})).unwrap(); + assert_eq!(sampled_type(&s, dst).first_style().size, 18.0); +} + +/// A pick that would extend text beyond the canvas keeps the previous sample and defaults. +#[test] +fn scaled_type_pick_canvas_rejection_preserves_sample_and_defaults() { + for mode in ["selection", "ids", "reverse", "range"] { + let mut s = session(); + let saved = id_of(s.execute("text.create", &json!({"x": 40, "y": 40, "text": "Saved", "size": 18, "color": "#0000ff"})).unwrap()); + let src = id_of(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Source", "size": 96, "color": "#ff0000"})).unwrap()); + let dst = id_of(s.execute("text.create", &json!({"x": MAX_COORD - 100.0, "y": 200, "text": "WWWW", "size": 10})).unwrap()); + s.execute("appearance.copyFrom", &json!({"source": saved.0, "ids": []})).unwrap(); + let pick = match mode { + "ids" | "reverse" => { + s.execute("select.none", &json!({})).unwrap(); + if mode == "ids" { json!({"source": src.0, "ids": [dst.0]}) } else { json!({"source": dst.0, "ids": [src.0], "reverse": true}) } + } + "range" => { + s.select_tool("type", ViewInfo::default()).unwrap(); + s.set_tool_option("textSelection", &json!({"id": dst.0, "start": 0, "end": 3})); + s.select_tool("eyedropper", ViewInfo::default()).unwrap(); + json!({"source": src.0}) + } + _ => json!({"source": src.0}), + }; + let doc = s.doc().unwrap().doc.clone(); + let selection = s.doc().unwrap().selection.clone(); + let range = s.tool_options(); + let paint = s.paint.clone(); + let sample = s.eyedropper_sample.clone(); + let history = undo_len(&s); + + let error = s.execute("appearance.copyFrom", &pick).unwrap_err(); + assert!(error.to_string().contains("coordinates out of range"), "{mode}: {error}"); + assert_eq!(s.doc().unwrap().doc, doc, "{mode}"); + assert_eq!(s.doc().unwrap().selection, selection, "{mode}"); + assert_eq!(s.tool_options(), range, "{mode}"); + assert_eq!(s.paint, paint, "{mode}"); + assert_eq!(s.eyedropper_sample, sample, "{mode}"); + assert_eq!(undo_len(&s), history, "{mode}"); + } +} + +/// Changing justification can move explicitly targeted point text outside the canvas even +/// when character copying is disabled. +#[test] +fn paragraph_only_pick_rejects_out_of_bounds_explicit_target() { + let mut s = session(); + let saved = id_of(s.execute("text.create", &json!({"x": 40, "y": 40, "text": "Saved", "color": "#0000ff"})).unwrap()); + let src = id_of(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Source", "color": "#ff0000"})).unwrap()); + s.execute("text.setStyle", &json!({"justify": "left"})).unwrap(); + let dst = id_of(s.execute("text.create", &json!({"x": 100, "y": 200, "text": "WWWW", "size": 16})).unwrap()); + s.execute("text.setStyle", &json!({"justify": "right"})).unwrap(); + s.execute("object.transform", &json!({"matrix": [1, 0, 0, 1, MAX_COORD - 101.0, 0]})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + s.execute("appearance.copyFrom", &json!({"source": saved.0, "ids": []})).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + let selection = s.doc().unwrap().selection.clone(); + let paint = s.paint.clone(); + let sample = s.eyedropper_sample.clone(); + let history = undo_len(&s); + + let pick = json!({"source": src.0, "ids": [dst.0], "apply": {"character": false}}); + let error = s.execute("appearance.copyFrom", &pick).unwrap_err(); + assert!(error.to_string().contains("coordinates out of range"), "{error}"); + assert_eq!(s.doc().unwrap().doc, doc); + assert_eq!(s.doc().unwrap().selection, selection); + assert_eq!(s.paint, paint); + assert_eq!(s.eyedropper_sample, sample); + assert_eq!(undo_len(&s), history); +} + +/// Disabled character copying and append preserve typography; reverse and cached picks convert it. +#[test] +fn scaled_type_pick_respects_options_and_reverse_sampling() { + let mut s = session(); + let src = id_of(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Source", "size": 12})).unwrap()); + s.execute("object.scale", &json!({"sx": 300, "sy": 200})).unwrap(); + let source = sampled_type(&s, src); + let expected = source.effective_char_style(&source.first_style()).unwrap(); + let dst = id_of(s.execute("text.create", &json!({"x": 300, "y": 100, "text": "Target", "size": 20})).unwrap()); + s.execute("object.scale", &json!({"sx": 50, "sy": 150})).unwrap(); + let before = sampled_type(&s, dst); + for options in [json!({"pickUp": {"character": false}}), json!({"apply": {"character": false}}), json!({"append": true})] { + let mut p = json!({"source": src.0, "ids": [dst.0]}); + p.as_object_mut().unwrap().extend(options.as_object().unwrap().clone()); + s.execute("appearance.copyFrom", &p).unwrap(); + assert_sampled_dimensions(&sampled_type(&s, dst).first_style(), &before.first_style()); + } + s.execute("select.set", &json!({"ids": [src.0]})).unwrap(); + s.execute("appearance.copyFrom", &json!({"source": dst.0, "reverse": true})).unwrap(); + let after = sampled_type(&s, dst); + assert_sampled_dimensions(&after.effective_char_style(&after.first_style()).unwrap(), &expected); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap(); + s.execute("appearance.copyFrom", &json!({"source": dst.0, "reverse": true})).unwrap(); + let cached = sampled_type(&s, dst); + assert_sampled_dimensions(&cached.effective_char_style(&cached.first_style()).unwrap(), &expected); +} + #[test] fn alt_click_applies_the_selection_to_the_clicked_object() { let mut s = session(); diff --git a/crates/engine/src/tests/linkspanel.rs b/crates/engine/src/tests/linkspanel.rs index 56784b69d..f029eccd3 100644 --- a/crates/engine/src/tests/linkspanel.rs +++ b/crates/engine/src/tests/linkspanel.rs @@ -296,6 +296,59 @@ fn link_info_stays_within_the_asking_callers_roots() { assert_ne!(i["status"], "ok", "{i}"); } +/// A confined caller's `links.list`, `links.check` and `links.info` replies, and its +/// `document.open` reply, leave out the path and folder of a linked file outside its read roots +/// and keep the row with the file's name. A file found by name inside the roots shows its path. +#[test] +fn link_paths_outside_the_read_roots_are_left_out() { + let away = Folder::new("lrr-away"); + let home = Folder::new("lrr-home"); + let pic = away.file("photo.png"); + let bytes = png(60, 30, RED); + write(&pic, &bytes); + let mut s = session(); + let id = place(&mut s, &pic); + let roots = crate::file_access::AutomationRoots::new(&[&home.0], &[]).unwrap().unwrap(); + let ask = |s: &mut Session, cmd: &str, p: Value| crate::file_access::confine(Some(&roots), || run(s, cmd, p)); + let hidden = |r: &Value| assert!(!r.to_string().contains("lrr-away"), "{r}"); + let list = ask(&mut s, "links.list", json!({})); + hidden(&list); + let row = &list["links"][0]; + assert_eq!((row["id"].as_u64(), row["name"].as_str(), row["status"].as_str()), (Some(id.0), Some("photo.png"), Some("missing")), "{list}"); + let check = ask(&mut s, "links.check", json!({})); + hidden(&check); + assert_eq!((check["links"][0]["name"].as_str(), check["missing"].as_u64()), (Some("photo.png"), Some(1)), "{check}"); + let info = ask(&mut s, "links.info", json!({"id": id.0})); + hidden(&info); + assert_eq!( + (info["fileName"].as_str(), info["status"].as_str(), info["linked"].as_bool()), + (Some("photo.png"), Some("missing"), Some(true)), + "{info}" + ); + for k in ["path", "link", "location", "fileSize", "modified", "created"] { + assert!(info.get(k).is_none(), "{k}: {info}"); + } + // Unconfined, the same replies carry the paths. + assert_eq!(run(&mut s, "links.list", json!({}))["links"][0]["path"], json!(pic)); + let i = run(&mut s, "links.info", json!({"id": id.0})); + assert_eq!((&i["path"], &i["link"], i["fileSize"].as_u64()), (&json!(pic), &json!(pic), Some(bytes.len() as u64)), "{i}"); + // Saved inside the roots next to a copy of the file: the copy is found by name and shown. + save(&mut s, &home.file("poster.vectorcraft")); + let copy = home.file("photo.png"); + write(©, &bytes); + let check = ask(&mut s, "links.check", json!({})); + hidden(&check); + assert_eq!((check["links"][0]["status"].as_str(), &check["links"][0]["found"]), (Some("ok"), &json!(copy)), "{check}"); + let list = ask(&mut s, "links.list", json!({})); + hidden(&list); + assert_eq!(list["links"][0]["found"], json!(copy), "{list}"); + // Opened again without the copy: the missing link's row has no path. + std::fs::remove_file(©).unwrap(); + let opened = ask(&mut Session::new(), "document.open", json!({"path": home.file("poster.vectorcraft")})); + hidden(&opened); + assert_eq!(opened["missingLinks"][0]["name"], "photo.png", "{opened}"); +} + /// A 600×300 pt image at the centre of the artboard, its placement options set to `options`, /// relinked to a 300×100 px file → the image's id and session. fn relinked(name: &str, options: Value) -> (Session, NodeId, Folder) { diff --git a/crates/engine/src/tests/outlinestroke.rs b/crates/engine/src/tests/outlinestroke.rs index 166c88725..d3394d148 100644 --- a/crates/engine/src/tests/outlinestroke.rs +++ b/crates/engine/src/tests/outlinestroke.rs @@ -73,14 +73,13 @@ fn arrowheads_are_part_of_the_outline() { assert_eq!(path_of(&n).subpaths.len(), 1, "line and head are one shape"); } -/// Outline Stroke of a Lens-profile line covers 7/12 of the full-width rectangle. +/// Outline Stroke of a Lens-profile line covers 2/3 of the full-width rectangle. #[test] -fn a_lens_profile_outlines_to_seven_twelfths_of_the_rectangle() { +fn a_lens_profile_outlines_to_two_thirds_of_the_rectangle() { let mut s = session(); line(&mut s, json!({"weight": 10, "profile": "lens"})); let n = outline(&mut s); - // Each half of the lens swells on a cubic, flat at the middle (#5714). - assert!(near(area(&n), 7.0 / 12.0 * 10.0 * 100.0, 2.0), "{}", area(&n)); + assert!(near(area(&n), 2.0 / 3.0 * 10.0 * 100.0, 2.0), "{}", area(&n)); } #[test] diff --git a/crates/engine/src/tests/placed_document.rs b/crates/engine/src/tests/placed_document.rs index 5871b85a3..e7d3c7cd7 100644 --- a/crates/engine/src/tests/placed_document.rs +++ b/crates/engine/src/tests/placed_document.rs @@ -1060,6 +1060,30 @@ fn link_info_gives_the_artboards_size_not_pixels() { ); } +/// A confined caller's Link Info of a placed document whose file is outside its read roots has no +/// `link`, `path` or `location`, and keeps the file's name. +#[test] +fn link_info_leaves_out_a_placed_documents_path_outside_the_read_roots() { + let away = Folder::new("placed-lrr-away"); + let home = Folder::new("placed-lrr-home"); + let src = away.file("logo.vectorcraft"); + source(&src, 100.0, 50.0, RED, 0); + let mut s = session(); + let id = place(&mut s, &src); + let roots = crate::file_access::AutomationRoots::new(&[&home.0], &[]).unwrap().unwrap(); + let i = crate::file_access::confine(Some(&roots), || s.execute("links.info", &json!({"id": id.0}))).unwrap(); + assert!(!i.to_string().contains("placed-lrr-away"), "{i}"); + assert_eq!( + (i["fileName"].as_str(), i["document"].as_bool(), i["status"].as_str()), + (Some("logo.vectorcraft"), Some(true), Some("missing")), + "{i}" + ); + let l = crate::file_access::confine(Some(&roots), || s.execute("links.list", &json!({}))).unwrap(); + assert!(!l.to_string().contains("placed-lrr-away") && l["links"][0]["name"] == "logo.vectorcraft", "{l}"); + let i = s.execute("links.info", &json!({"id": id.0})).unwrap(); + assert_eq!((&i["link"], &i["path"]), (&json!(src), &json!(src)), "unconfined: {i}"); +} + /// Save As reads its defaults from the catalog; using them must retain current editable links. #[test] fn native_save_catalog_defaults_keep_placed_documents_editable() { diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index cb0f191c8..7587a69b5 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -2326,6 +2326,194 @@ fn a_failed_store_write_keeps_the_stored_module_with_its_grant() { vectorcraft_plugins::registry::revoke(id); } +/// Runs the test `name` of this module again in a process of its own → whether this is that +/// process. The list of disabled plug-ins is kept for the whole process, and the other tests here +/// read it again each time they attach a plug-in store. +fn in_own_process(name: &str) -> bool { + const CHILD: &str = "VECTORCRAFT_TEST_PLUGIN_DISABLED_LIST"; + if std::env::var_os(CHILD).is_some() { + return true; + } + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", &format!("tests::plugins::{name}"), "--nocapture"]) + .env(CHILD, "1") + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + output.status.success() && stdout.contains("test result: ok. 1 passed; 0 failed;"), + "{name} failed in its own process:\n{stdout}\n{}", + String::from_utf8_lossy(&output.stderr), + ); + false +} + +/// An update whose grant can't be written, and whose old module can't be put back either, stays in +/// the store: a disabled plug-in is turned on, as for any update the store holds. The old grant +/// doesn't cover the new module, so the next start skips it until it is approved. +#[test] +fn an_update_left_in_the_store_by_a_failed_put_back_turns_a_disabled_plugin_on() { + if !in_own_process("an_update_left_in_the_store_by_a_failed_put_back_turns_a_disabled_plugin_on") { + return; + } + use vectorcraft_plugins::{permissions::sha256_hex, registry, safe_mode}; + let folder = crate::tests_links::Folder::new("plugin-store-failed-put-back-update"); + let id = "test.engine.permissions.putbackupdate"; + let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []})); + let changed = asking(id, r#"["doc.read","doc.write"]"#, &json!({"objects": []})); + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&first), "grant": ["doc.read"]})); + run(&mut s, "plugin.setEnabled", json!({"id": id, "enabled": false})); + let grants = folder.0.join("grants.json"); + let recorded = std::fs::read(&grants).unwrap(); + std::fs::remove_file(&grants).unwrap(); + std::fs::create_dir_all(grants.join("block")).unwrap(); + + crate::store::PUT_BACK_FAILS.set(true); + let update = json!({"dataBase64": vectorcraft_format::base64_encode(&changed), "grant": ["doc.read", "doc.write"], "replace": true}); + let installed = s.execute("plugin.install", &update); + crate::store::PUT_BACK_FAILS.set(false); + assert_eq!(installed.unwrap()["hash"], sha256_hex(&changed), "the new module runs in this session"); + assert_eq!(registry::get(id).map(|p| p.hash().to_string()), Some(sha256_hex(&changed))); + assert_eq!(std::fs::read(s.stored_plugin_file(id).unwrap()).unwrap(), changed, "the store holds the new module"); + assert!(safe_mode::disabled().is_empty(), "the module the store holds is turned on"); + let saved = std::fs::read(folder.0.join(crate::store::DISABLED_FILE)).unwrap(); + assert!(safe_mode::parse_disabled_file(&saved).is_empty(), "nothing disabled is saved"); + + std::fs::remove_dir_all(&grants).unwrap(); + std::fs::write(&grants, &recorded).unwrap(); + registry::remove(id); + registry::revoke(id); + let mut next = session(); + next.attach_plugin_store(folder.0.clone()); + assert!(registry::get(id).is_none(), "the old grant doesn't cover the new module"); + safe_mode::set_disabled(vec![]); +} + +/// An update whose grant can't be written, and whose put-back writes the old module and then +/// reports a failure, leaves the old module in the store: a disabled plug-in stays disabled, and the +/// next start doesn't load it. +#[test] +fn an_update_put_back_that_fails_after_writing_keeps_a_disabled_plugin_off() { + if !in_own_process("an_update_put_back_that_fails_after_writing_keeps_a_disabled_plugin_off") { + return; + } + use vectorcraft_plugins::{registry, safe_mode}; + let folder = crate::tests_links::Folder::new("plugin-store-put-back-fails-after-write"); + let id = "test.engine.permissions.putbacklate"; + let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []})); + let changed = asking(id, r#"["doc.read","doc.write"]"#, &json!({"objects": []})); + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&first), "grant": ["doc.read"]})); + run(&mut s, "plugin.setEnabled", json!({"id": id, "enabled": false})); + let disabled = safe_mode::disabled(); + assert!(!disabled.is_empty()); + let grants = folder.0.join("grants.json"); + let recorded = std::fs::read(&grants).unwrap(); + std::fs::remove_file(&grants).unwrap(); + std::fs::create_dir_all(grants.join("block")).unwrap(); + + crate::store::PUT_BACK_FAILS_AFTER_WRITE.set(true); + let update = json!({"dataBase64": vectorcraft_format::base64_encode(&changed), "grant": ["doc.read", "doc.write"], "replace": true}); + let installed = s.execute("plugin.install", &update); + crate::store::PUT_BACK_FAILS_AFTER_WRITE.set(false); + installed.unwrap(); + assert_eq!(std::fs::read(s.stored_plugin_file(id).unwrap()).unwrap(), first, "the store holds the old module"); + assert_eq!(safe_mode::disabled(), disabled, "the plug-in stays disabled"); + let saved = std::fs::read(folder.0.join(crate::store::DISABLED_FILE)).unwrap(); + assert!(!safe_mode::parse_disabled_file(&saved).is_empty(), "the disabled list on disk is kept"); + + std::fs::remove_dir_all(&grants).unwrap(); + std::fs::write(&grants, &recorded).unwrap(); + registry::remove(id); + registry::revoke(id); + let mut next = session(); + next.attach_plugin_store(folder.0.clone()); + assert!(registry::get(id).is_none(), "the disabled old module doesn't load"); + safe_mode::set_disabled(vec![]); +} + +/// A module without permissions whose install can't remove the old grant, and whose put-back writes +/// the old module and then reports a failure, leaves the old module in the store: a disabled plug-in +/// stays disabled. +#[test] +fn a_plain_update_put_back_that_fails_after_writing_keeps_a_disabled_plugin_off() { + if !in_own_process("a_plain_update_put_back_that_fails_after_writing_keeps_a_disabled_plugin_off") { + return; + } + use vectorcraft_plugins::safe_mode; + let folder = crate::tests_links::Folder::new("plugin-store-plain-put-back-fails-after-write"); + let id = "test.engine.permissions.plainputbacklate"; + let empty = json!({"objects": []}); + let approved = asking(id, r#"["doc.read"]"#, &empty); + let plain = asking(id, "null", &empty); + let grants = folder.0.join("grants.json"); + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&approved), "grant": ["doc.read"]})); + run(&mut s, "plugin.setEnabled", json!({"id": id, "enabled": false})); + let disabled = safe_mode::disabled(); + assert!(!disabled.is_empty()); + let file = s.stored_plugin_file(id).unwrap(); + // `1e9` is written back as `1000000000.0`, which makes the rewritten file larger than the 1 MiB that is read. + let entry = serde_json::from_slice::>(&std::fs::read(&grants).unwrap()).unwrap().remove(0); + let full = format!(r#"[{entry},{{"n":[{}]}}]"#, vec!["1e9"; 200_000].join(",")).into_bytes(); + std::fs::write(&grants, &full).unwrap(); + + crate::store::PUT_BACK_FAILS_AFTER_WRITE.set(true); + let installed = s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&plain), "replace": true})); + crate::store::PUT_BACK_FAILS_AFTER_WRITE.set(false); + installed.unwrap(); + assert_eq!(std::fs::read(&file).unwrap(), approved, "the store holds the old module"); + assert_eq!(safe_mode::disabled(), disabled, "the plug-in stays disabled"); + let saved = std::fs::read(folder.0.join(crate::store::DISABLED_FILE)).unwrap(); + assert!(!safe_mode::parse_disabled_file(&saved).is_empty(), "the disabled list on disk is kept"); + + std::fs::remove_file(&grants).unwrap(); + run(&mut s, "plugin.remove", json!({"id": id})); + safe_mode::set_disabled(vec![]); +} + +/// A first install whose grant can't be written, and whose module can't be taken out of the store +/// again, stays in the store: the plug-in's file is turned on. No grant covers the module, so the +/// next start skips it until it is approved. +#[test] +fn a_first_install_left_in_the_store_by_a_failed_put_back_turns_its_file_on() { + if !in_own_process("a_first_install_left_in_the_store_by_a_failed_put_back_turns_its_file_on") { + return; + } + use vectorcraft_plugins::{registry, safe_mode}; + let folder = crate::tests_links::Folder::new("plugin-store-failed-put-back-first"); + let id = "test.engine.permissions.putbackfirst"; + let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []})); + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + let module = s.stored_plugin_file(id).unwrap(); + run(&mut s, "plugin.setEnabled", json!({"file": module, "enabled": false})); + let grants = folder.0.join("grants.json"); + std::fs::create_dir_all(grants.join("block")).unwrap(); + + crate::store::PUT_BACK_FAILS.set(true); + let installed = s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&first), "grant": ["doc.read"]})); + crate::store::PUT_BACK_FAILS.set(false); + installed.unwrap(); + assert!(registry::get(id).is_some(), "the module runs in this session"); + assert_eq!(std::fs::read(&module).unwrap(), first, "the store holds the module"); + assert!(safe_mode::disabled().is_empty(), "the file the store holds is turned on"); + let saved = std::fs::read(folder.0.join(crate::store::DISABLED_FILE)).unwrap(); + assert!(safe_mode::parse_disabled_file(&saved).is_empty(), "nothing disabled is saved"); + + std::fs::remove_dir_all(&grants).unwrap(); + registry::remove(id); + registry::revoke(id); + let mut next = session(); + next.attach_plugin_store(folder.0.clone()); + assert!(registry::get(id).is_none(), "no grant covers the module"); + safe_mode::set_disabled(vec![]); +} + /// A plug-in folder reports a changed module as needing approval and still loads the other files; /// granted on its own, the changed module loads from the folder. A session without a store uses the /// grants held by the process. diff --git a/crates/engine/src/tests/script_io.rs b/crates/engine/src/tests/script_io.rs index 4f9f193f5..d8c5d1d2e 100644 --- a/crates/engine/src/tests/script_io.rs +++ b/crates/engine/src/tests/script_io.rs @@ -55,6 +55,42 @@ fn file_constructor_identity_and_folder_path() { let _ = std::fs::remove_dir_all(base); } +/// `exists` is true only when the path names an entry of the object's own kind. +#[test] +fn script_file_exists_checks_the_kind() { + let base = folder("kind"); + write(&base.join("main/data.txt"), b"x"); + std::fs::create_dir_all(base.join("main/sub")).unwrap(); + let log = + logged(&base, "vc.log([new File('sub').exists, new Folder('data.txt').exists, new File('data.txt').exists, new Folder('sub').exists]);"); + assert_eq!(log, json!([[false, false, true, true]])); + let _ = std::fs::remove_dir_all(base); +} + +/// A property a script puts on `Object.prototype` is not read as part of what the app returns. +#[test] +fn script_file_ignores_inherited_result_fields() { + let base = folder("inherited"); + write(&base.join("main/data.txt"), b"data"); + let log = logged( + &base, + "Object.prototype.error = 'inherited'; Object.prototype.exists = true;\n\ + var f = new File('data.txt'); var opened = f.open('r');\n\ + vc.log([f.exists, new File('nope').exists, opened, f.error, f.read()]);", + ); + assert_eq!(log, json!([[true, false, true, "", "data"]])); + let _ = std::fs::remove_dir_all(base); +} + +/// `File.encode`, `File.decode`, `File.isEncodingAvailable` and the `Folder` locations are counted as +/// calls to the app. +#[test] +fn script_file_class_functions_are_counted() { + let source = "File.encode('x'); File.decode('y'); Folder.isEncodingAvailable('UTF-8'); Folder.temp;"; + let r = Session::new().execute("script.run", &json!({"source": source})).unwrap(); + assert_eq!(r["hostCallCounts"]["fileCalls"], 4, "{r}"); +} + #[test] fn file_names_are_uri_encoded_and_current_relative() { let base = folder("names"); @@ -74,7 +110,7 @@ fn file_names_are_uri_encoded_and_current_relative() { assert_eq!( log, json!([ - ["caf%C3%A9%20x.txt", "caf\u{e9} x.txt", "caf%C3%A9%20x.txt", true, "%C3%A9%20%2F", "\u{e9}", "sub/y.txt", true, true], + ["caf%C3%A9%20x.txt", "caf\u{e9} x.txt", "caf%C3%A9%20x.txt", true, "%C3%A9%20/", "\u{e9}", "sub/y.txt", true, true], ["sub", "w", "w"], ["literal%20name.txt", "literal%2520name.txt", "literal%2520name.txt", true, "p%41", "p%41"] ]) diff --git a/crates/engine/src/tests/widthpoints.rs b/crates/engine/src/tests/widthpoints.rs index 4c837c300..6aa324aa9 100644 --- a/crates/engine/src/tests/widthpoints.rs +++ b/crates/engine/src/tests/widthpoints.rs @@ -221,9 +221,9 @@ fn new_width_point_adjust_adjoining_uses_sampled_widths() { let (mut s, id) = line_session(); let original = vec![(0.0, 9.0, 9.0), (0.25, 1.0, 0.0), (0.75, 3.0, 0.0), (1.0, 7.0, 7.0)]; s.execute("stroke.widthProfile.set", &json!({"ids": [id.0], "points": original})).unwrap(); - // At t=0.5 the old left side is where the profile's curve is there (#5714) and the right - // side is 0pt. Indexed editing of that sampled point is the existing convention: scale the - // nonzero side, add from zero. + // At t=0.5 the old left side is where the profile's curve is there and the right side is + // 0pt. Indexed editing of that sampled point is the existing convention: scale the nonzero + // side, add from zero. let sampled = vectorcraft_doc::WidthProfile { points: original }.at(0.5).0; let index = if indexed { Some(set(&mut s, json!({"id": id.0, "t": 0.5, "left": sampled * 5.0, "right": 0}))) } else { None }; set(&mut s, json!({"id": id.0, "index": index, "t": 0.5, "left": 20, "right": 5, "adjustAdjoining": true})); diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 586f177ae..67bb04c33 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -1,4 +1,4 @@ -//! Untrusted files never crash the app: garbage, truncated, mutated and hostile SVG, PDF and DXF input, +//! Untrusted files never crash the app: garbage, truncated, mutated and hostile SVG, PDF, DXF and HP-GL input, //! mutated raster images (Photoshop documents and JPEG XL images among them) placed with File → Place, and swatch (`.vcswatches`, `.gpl`, `.ase`), graphic style (`.vcstyles`), flattener preset //! (`.vcflattener`) and export preset (`.vcexportpresets`) libraries, shortcut sets and action sets other apps save as text (`.kys`, `.aia`), and native files (compressed, damaged, saved for older versions), //! must load as an error or as a document that then renders and exports, without a panic; nor may @@ -2935,6 +2935,73 @@ proptest! { } } +// ---------- HP-GL plot files: opened and placed ---------- + +/// A plot file with every kind of instruction the reader plays, printer escapes around it. +const HPGL_SAMPLE: &[u8] = b"\x1b%-12345X@PJL ENTER LANGUAGE=HPGL2\r\n\x1bE\x1b%0BIN;BP1,\"job\";CO\"sample\";NP16;PC9,10,20,30;CR0,100,0,100,0,100;\ +SP1;PW0.5;WU0;LA1,4,2,4,3,7;LT2,5,1;PA0,0;PD1000,0,1000,1000;PU;LT;PR;PD-500,0,0,-500;PA2000,2000;CI300,5;AA2000,2500,120;AR0,100,-90;\ +AT2500,2500,3000,2000;RT100,100,200,0;BZ0,100,100,100,100,0;BR10,10,20,10,30,0;UL3,1,2,1;LT-3,2;EA3000,3000;ER100,-100;FT10,40;RA3500,3500;\ +RR200,200;WG400,30,120;EW-400,0,360;PM0;PD4000,4000,4500,4000,4500,4500;PM1;PU4100,4100;PD4200,4100,4200,4200;PM2;FP1;EP;FT3,10,45;FP;\ +IP0,0,10160,7620;SC0,100,0,100,1,20,80;PU10,10;PD20,20;SC0,10,0,10,2;PD5,5;SC;RO90;PD100,100;RO;IR10,10,90,90;IW0,0,5000,5000;\ +DT*,1;LBlabel*SMx;PA0,0;SM;PE:\xc1<=\xbf\xc0\xc2\xc2;LB\x03PG;\x1b%0A\x1b*r1A\x1b*b4W\x01\x02\x03\x04\x1b*rC\x1b%1BPD1,1;\x1b%-12345X"; + +/// The sample the mutations start from opens as a plot with art. +#[test] +fn the_hpgl_sample_opens() { + let l = vectorcraft_engine::cmd::fileio::load("x.plt", HPGL_SAMPLE).unwrap(); + assert_eq!(l.format.id, "hpgl"); + let mut paths = 0; + l.doc.walk(|n| paths += usize::from(matches!(n.kind, vectorcraft_doc::NodeKind::Path { .. }))); + assert!(paths > 15, "{paths} paths, warnings {:?}", l.warnings); +} + +/// A random instruction: a mnemonic the reader plays (or one it doesn't) with hostile numbers. +fn arb_hpgl_instruction() -> impl Strategy { + let op = prop::sample::select(vec![ + "IN", "DF", "PU", "PD", "PA", "PR", "SP", "PW", "WU", "NP", "PC", "CR", "LT", "UL", "LA", "FT", "SC", "IP", "IR", "RO", "CI", "AA", "AR", + "AT", "RT", "BZ", "BR", "EA", "ER", "RA", "RR", "EW", "WG", "PM", "FP", "EP", "LB", "DT", "SM", "CO", "PG", "IW", "PE", "ZZ", + ]); + let number = prop_oneof![ + Just("0".to_string()), + Just("-1".to_string()), + (-3000i32..3000).prop_map(|v| v.to_string()), + Just("99999999999999999999999".to_string()), + Just("0.000001".to_string()), + Just("32767".to_string()), + Just("-32768".to_string()), + Just("360".to_string()), + ]; + (op, prop::collection::vec(number, 0..8)).prop_map(|(o, n)| format!("{o}{};", n.join(","))) +} + +proptest! { + #![proptest_config(config())] + + #[test] + fn random_hpgl_instructions_never_panic(instructions in prop::collection::vec(arb_hpgl_instruction(), 0..40)) { + let bytes = format!("IN;SP1;{}", instructions.concat()).into_bytes(); + survive_metafile("HP-GL instructions", "x.plt", &bytes)?; + } + + #[test] + fn mutated_hpgl_files_never_panic(cut in 0usize..1200, edits in prop::collection::vec((0usize..1200, any::()), 0..16)) { + let mut bytes = HPGL_SAMPLE.to_vec(); + let n = bytes.len(); + for &(at, b) in &edits { + bytes[at % n] = b; + } + bytes.truncate(cut.max(4)); + survive_metafile("mutated HP-GL", "x.plt", &bytes)?; + } + + #[test] + fn hpgl_garbage_never_panics(bytes in prop::collection::vec(any::(), 0..400)) { + let mut b = b"IN;SP1;PD".to_vec(); + b.extend(bytes); + survive_metafile("HP-GL garbage", "x.hpgl", &b)?; + } +} + // ---------- print presets files ---------- /// Import print presets from `data`, then lay out (and print, when short) the rich document with diff --git a/crates/js/src/builtins/extensions.rs b/crates/js/src/builtins/extensions.rs index 15ebbe734..4ef9b8210 100644 --- a/crates/js/src/builtins/extensions.rs +++ b/crates/js/src/builtins/extensions.rs @@ -9,14 +9,18 @@ //! a script's own method: a getter, a function, a date, a regular expression, an error, a boxed //! primitive, an array with properties besides its elements, an object whose prototype is not //! `Object.prototype`, a property that is not enumerable or any other object that is not plain -//! data throws a `TypeError`, and so does an object that contains itself. A value nested deeper, with more entries or longer text than the +//! data throws a `TypeError`, and so does an object that contains itself. A literal makes an +//! extensible object whose properties are writable and configurable and an array whose `length` +//! is writable, so a property that is not writable or not configurable, an object or array that is +//! not extensible (after `Object.preventExtensions`, `Object.seal` or `Object.freeze`) and an +//! array with a read-only `length` throw a `TypeError` too. A value nested deeper, with more entries or longer text than the //! realm's [`crate::DataLimits`] allow throws a `RangeError`. Every value written takes fuel. use std::cell::{Cell, RefCell}; use std::rc::Rc; use super::{Builder, arg, method}; -use crate::heap::{Class, NativeFn, ObjId, Prop, key}; +use crate::heap::{CONFIGURABLE, Class, NativeFn, ObjId, Object, Prop, WRITABLE, key}; use crate::interp::{ErrorType, Interp, JsResult}; use crate::value::{Value, array_index, index_key, number_to_str}; use crate::{ScriptItem, bytecode::Code}; @@ -289,10 +293,14 @@ impl Source { Class::Ordinary => return Err(interp.type_error(NOT_PLAIN_OBJECT)), _ => None, }; + let fixed = shape.as_ref().and(fixed_state(o)); let Some(shape) = shape else { let what = format!("toSource: a {} cannot be written as source", interp.object(id)?.class.name()); return Err(interp.type_error(&what)); }; + if let Some(what) = fixed { + return Err(interp.type_error(what)); + } self.ancestors.push(id); let r = match shape { Err(length) => self.array(interp, id, length), @@ -347,6 +355,23 @@ const NOT_PLAIN_ARRAY: &str = "toSource: an array with properties besides its elements, a property that is not enumerable or another prototype cannot be written as source"; const NOT_PLAIN_OBJECT: &str = "toSource: an object with a property that is not enumerable, or whose prototype is not Object.prototype, cannot be written as source"; +const NOT_EXTENSIBLE: &str = "toSource: a non-extensible object cannot be written as source"; +const READ_ONLY_LENGTH: &str = "toSource: an array with a read-only length cannot be written as source"; +const FIXED_PROPERTY: &str = "toSource: a property that is not writable or configurable cannot be written as source"; + +/// Why the literal for the array or plain object `o` would not make it again: `o` is not +/// extensible, its `length` is read-only, or one of its data properties is not writable or not +/// configurable. A literal makes each of these open. +fn fixed_state(o: &Object) -> Option<&'static str> { + if !o.extensible { + return Some(NOT_EXTENSIBLE); + } + if matches!(o.class, Class::Array { length_writable: false, .. }) { + return Some(READ_ONLY_LENGTH); + } + let open = WRITABLE | CONFIGURABLE; + o.props.iter().any(|(_, p)| matches!(p, Prop::Data { flags, .. } if flags & open != open)).then_some(FIXED_PROPERTY) +} /// The getter behind `item`; [`ScriptItem::Global`] is a value and has none. pub(crate) fn getter(item: ScriptItem) -> Option<(&'static str, NativeFn)> { diff --git a/crates/js/src/extension_tests.rs b/crates/js/src/extension_tests.rs index 19a20ab5b..8336643e4 100644 --- a/crates/js/src/extension_tests.rs +++ b/crates/js/src/extension_tests.rs @@ -126,6 +126,45 @@ fn to_source_refuses_what_is_not_plain_data() { assert!(thrown(&mut r, "toSource({get g() { throw 'ran'; }})").starts_with("TypeError"), "the getter does not run"); } +#[test] +fn to_source_refuses_flags_and_extensibility_a_literal_does_not_make() { + let mut r = realm(); + let property = "TypeError: toSource: a property that is not writable or configurable cannot be written as source"; + let extensible = "TypeError: toSource: a non-extensible object cannot be written as source"; + let length = "TypeError: toSource: an array with a read-only length cannot be written as source"; + for (source, want) in [ + ("var o = {}; Object.defineProperty(o, 'a', {value: 1, enumerable: true, configurable: true}); toSource(o)", property), + ("var o = {}; Object.defineProperty(o, 'a', {value: 1, enumerable: true, writable: true}); toSource(o)", property), + ("var a = [1, 2]; Object.defineProperty(a, '1', {writable: false}); toSource(a)", property), + ("var a = [1, 2]; Object.defineProperty(a, '0', {configurable: false}); a.toSource()", property), + ("var o = {}; Object.defineProperty(o, 'a', {value: 1, enumerable: true}); toSource({x: [{b: o}]})", property), + ("toSource(Object.preventExtensions({}))", extensible), + ("toSource(Object.preventExtensions([]))", extensible), + ("toSource(Object.seal({a: 1}))", extensible), + ("toSource(Object.freeze({a: 1}))", extensible), + ("toSource(Object.seal([1]))", extensible), + ("toSource(Object.freeze([1]))", extensible), + ("toSource([1, {b: Object.freeze({})}])", extensible), + ("var a = []; Object.defineProperty(a, 'length', {writable: false}); toSource(a)", length), + ("var a = [1, , 3]; Object.defineProperty(a, 'length', {writable: false}); toSource({a: a})", length), + ] { + assert_eq!(thrown(&mut r, source), want, "{source}"); + } + // The refused states are ones the literal would lose: evaluated, it is open again. + let lost = "var o = Object.freeze({a: 1}); var w = eval('({\"a\":1})'); + [Object.isExtensible(o), Object.isExtensible(w), Object.getOwnPropertyDescriptor(w, 'a').writable].join()"; + assert_eq!(text(&mut r, lost), "false,true,true"); +} + +#[test] +fn to_source_writes_properties_defined_with_every_flag_on() { + let mut r = realm(); + let source = "var o = {}; Object.defineProperty(o, 'a', {value: 1, writable: true, enumerable: true, configurable: true}); + var a = [0]; Object.defineProperty(a, '0', {value: 2, writable: true, enumerable: true, configurable: true}); + toSource({o: o, a: a, e: {}, f: [], s: [1, , 3]})"; + assert_eq!(text(&mut r, source), r#"({"o":({"a":1}), "a":[2], "e":({}), "f":[], "s":[1, , 3]})"#); +} + #[test] fn to_source_of_a_string_with_many_escapes_throws_a_range_error_before_the_heap_limit() { let mut r = realm(); diff --git a/crates/js/src/file_classes.rs b/crates/js/src/file_classes.rs index 715834fd5..cabf7c84a 100644 --- a/crates/js/src/file_classes.rs +++ b/crates/js/src/file_classes.rs @@ -70,8 +70,13 @@ fn message(i: &mut Interp, name: &str) -> JsResult { i.get_value(&text, &key(name)) } +/// The field `name` of a record the host gave: its own data property, `undefined` when it has none, +/// so that a property a script put on `Object.prototype` is not read as part of it. fn field(i: &mut Interp, v: &Value, name: &str) -> JsResult { - i.get_value(v, &key(name)) + match v.as_object() { + Some(o) => slot(i, o, name), + None => Ok(Value::Undefined), + } } /// The value of the own data property `name` of `o`, `undefined` when it has none. @@ -310,8 +315,19 @@ fn parent(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult instance(i, proto, up) } +/// `exists`: whether the path names something of the object's own kind, a folder for a `Folder` +/// and anything else for a `File`. fn exists(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { - Ok(Value::Bool(stat_field(i, &this, "exists", "exists", Value::Bool(false))?.truthy())) + let folder = classes(i)?.folder_proto; + let want_dir = match this.as_object() { + Some(o) => i.object(o)?.proto == Some(folder), + None => false, + }; + let Some(r) = stat(i, &this, "exists")? else { return Ok(Value::Bool(false)) }; + i.rooted(std::slice::from_ref(&r), |i| { + let dir = field(i, &r, "dir")?.truthy(); + Ok(Value::Bool(field(i, &r, "exists")?.truthy() && dir == want_dir)) + }) } fn created(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { @@ -607,7 +623,7 @@ fn get_files(i: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult Option { let l = v.hypot(); @@ -37,7 +40,7 @@ pub(crate) fn end_tangent(c: &CubicBez) -> Vec2 { /// /// `t0` is the unit tangent leaving `pts[0]`; `t1` is the unit tangent arriving at the last point /// (forward direction). Consecutive output cubics are G1 continuous. -pub(crate) fn fit_cubics(pts: &[Point], t0: Vec2, t1: Vec2, tol: f64) -> Vec { +pub fn fit_cubics(pts: &[Point], t0: Vec2, t1: Vec2, tol: f64) -> Vec { let pts = dedup(pts); if pts.len() < 2 { return Vec::new(); @@ -48,6 +51,43 @@ pub(crate) fn fit_cubics(pts: &[Point], t0: Vec2, t1: Vec2, tol: f64) -> Vec Vec { + let pts = dedup(pts); + let mut out = Vec::new(); + if pts.len() < 2 { + return out; + } + fit_through_rec(&pts, t0, t1, tol.max(1e-9), &mut out, 0); + out +} + +/// Fits all of `pts` with one cubic, splitting at its worst point until each piece is within `tol` +/// of its points (see [`fit_cubics_through`]). `depth` bounds the recursion on hostile points. +fn fit_through_rec(pts: &[Point], t0: Vec2, t1: Vec2, tol: f64, out: &mut Vec, depth: usize) { + let n = pts.len(); + if n == 2 { + let d = pts[0].distance(pts[1]) / 3.0; + out.push(CubicBez::new(pts[0], pts[0] + t0 * d, pts[1] - t1 * d, pts[1])); + return; + } + let (curve, err, split) = fit_single_tol(pts, t0, t1, tol); + if err <= tol || depth >= MAX_SPLITS { + out.push(curve); + return; + } + let split = split.clamp(1, n - 2); + let tc = unit(pts[split + 1] - pts[split - 1]).unwrap_or(t0); + fit_through_rec(&pts[..=split], t0, tc, tol, out, depth + 1); + fit_through_rec(&pts[split..], tc, t1, tol, out, depth + 1); +} + /// The curves of `pieces`, which follow each other along `pts` from its first point, leaving it /// along `t0`. Each curve takes up to [`MAX_JOINS`] of the pieces after its first one, as long as /// the fit of them together is within `tol`. @@ -152,6 +192,32 @@ pub(crate) fn fit_single(pts: &[Point], t0: Vec2, t1: Vec2) -> (CubicBez, f64, u fit_single_from(pts, chord_params(pts), t0, t1) } +/// [`fit_single`] that stops reparameterising once the curve is within `tol` of its points: a +/// curve already that close needs no better fit, and each Newton round reads every point. +fn fit_single_tol(pts: &[Point], t0: Vec2, t1: Vec2, tol: f64) -> (CubicBez, f64, usize) { + let mut u = chord_params(pts); + let mut best = generate(pts, &u, t0, t1); + let (mut best_err, mut best_split) = max_error(pts, &best, &u); + for _ in 0..MAX_REPARAM { + if best_err <= tol { + break; + } + for (i, ui) in u.iter_mut().enumerate() { + *ui = newton(&best, pts[i], *ui); + } + let c = generate(pts, &u, t0, t1); + let (e, s) = max_error(pts, &c, &u); + if e < best_err { + best = c; + best_err = e; + best_split = s; + } else { + break; + } + } + (best, best_err, best_split) +} + /// [`fit_single`] starting from the parameters `u` (one per point, 0 to 1) instead of the /// chord-length ones. pub(crate) fn fit_single_from(pts: &[Point], u: Vec, t0: Vec2, t1: Vec2) -> (CubicBez, f64, usize) { @@ -334,6 +400,25 @@ mod tests { assert!(out[0].p1.distance(c.p1) < 1.0, "{:?}", out[0]); } + /// A curve's samples are followed between the points, not chopped to the chords through + /// them: a smooth outline fitted from coarse samples must keep its bow (an outline's area + /// depends on it), unlike a polyline's samples. + #[test] + fn through_fit_bows_between_coarse_points() { + // The parabola y = 4t(1 - t) over x = 100t, sampled at 8 points. + let node = |t: f64| Point::new(100.0 * t, 4.0 * t * (1.0 - t)); + let pts: Vec = (0..=8).map(|i| node(i as f64 / 8.0)).collect(); + let (t0, t1) = (Vec2::new(1.0, 0.04).normalize(), Vec2::new(1.0, -0.04).normalize()); + let out = fit_cubics_through(&pts, t0, t1, 0.001); + for c in &out { + for k in 0..=100 { + let q = c.eval(k as f64 / 100.0); + let want = 4.0 * (q.x / 100.0) * (1.0 - q.x / 100.0); + assert!((q.y - want).abs() < 0.01, "{:?} at {q:?} wants {want}", c); + } + } + } + #[test] fn splits_when_needed() { // A zig-zag cannot be fitted with one cubic. diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index b7af34c89..291c95678 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -26,6 +26,7 @@ pub use edit::{ remove_anchor, remove_redundant_points, simplify, simplify_level_tolerance, simplify_part, simplify_with, smooth, smooth_level, smooth_options, split_into_grid, }; +pub use fit::{fit_cubics, fit_cubics_through}; pub use offset::{Cap, Join, MAX_OFFSET_SEGMENT_PAIRS, offset_path, outline_stroke, stroke_region, try_offset_path}; pub use pathfinder::{FaceMerger, PathfinderOp, Region, Shape, merge_regions, pathfinder, pathfinder_with, region_at, regions, regions_within}; pub use planar::{ diff --git a/crates/pdf/src/export/entry.rs b/crates/pdf/src/export/entry.rs index a53394f0f..5a19e6256 100644 --- a/crates/pdf/src/export/entry.rs +++ b/crates/pdf/src/export/entry.rs @@ -63,9 +63,11 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha // Preserve Editing: the native document as an embedded file (`check` refused PDF/A with it). let mut warnings = set.warnings(); if raised.is_some() { - warnings.push( - "PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6".into(), - ); + warnings.push(if opts.settings.compatibility == Compatibility::Pdf14 { + "PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6".into() + } else { + "PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6".into() + }); } let native = opts.native.as_deref().filter(|_| set.preserve_editing); if let Some(native) = native { @@ -199,9 +201,9 @@ fn writes_user_units(set: &PdfSettings) -> bool { } /// `long_pages_in_points` is on in a file without a standard. PDF/X files write long pages in -/// points anyway, and PDF/A files from PDF 1.5 on keep the `UserUnit`, which keeps their page -/// boundaries within 14,400 units (ISO 19005-2, 6.1.13). A PDF/A file at PDF 1.4 has no -/// `UserUnit` and writes long pages in points either way. +/// points anyway, and PDF/A files keep the `UserUnit`, which keeps their page boundaries within +/// 14,400 units (ISO 19005-2, 6.1.13). A PDF/A file at PDF 1.4 is written as PDF 1.6 to have one +/// ([`for_long_pages`]). fn long_pages_in_points(set: &PdfSettings) -> bool { set.long_pages_in_points && set.standard == Standard::None } @@ -239,10 +241,20 @@ fn for_long_pages(set: &PdfSettings, pages: &[(usize, PageBoxes)]) -> Result MAX_PDF14_REAL && (side as f32) as i32 as f32 != side as f32; if let Some((i, b)) = pages.iter().find(|(_, b)| real(b.media.width()) || real(b.media.height())) { - page_too_long(set, *i, b.media.width().max(b.media.height()))?; + page_too_long(raised.as_ref().unwrap_or(set), *i, b.media.width().max(b.media.height()))?; } Ok(raised) } diff --git a/crates/pdf/src/tests/marks.rs b/crates/pdf/src/tests/marks.rs index 47ee3dfa7..603138bd4 100644 --- a/crates/pdf/src/tests/marks.rs +++ b/crates/pdf/src/tests/marks.rs @@ -256,14 +256,48 @@ fn every_preset_and_version_keeps_a_20_m_page_or_says_why_not() { } } } - // PDF/X-4 and PDF/A-2b files at PDF 1.4 have no `UserUnit` either. + // PDF/X-4 files at PDF 1.4 have no `UserUnit` either; PDF/A-2b files at PDF 1.4 are written as + // PDF 1.6 to have one. let at_14 = |standard| report(&d, PdfSettings { standard, compatibility: Compatibility::Pdf14, ..PdfSettings::default() }); refused(at_14(Standard::PdfX4).err().unwrap(), "PDF 1.4"); - refused(at_14(Standard::PdfA2b).err().unwrap(), "PDF 1.4"); + let r = at_14(Standard::PdfA2b).unwrap(); + assert!(r.bytes.starts_with(b"%PDF-1.6")); + assert_eq!(raw_numbers(&r.bytes, "/UserUnit", 1), [3.938]); +} + +/// PDF/A-2 limits page boundaries to 14,400 units (ISO 19005-2, 6.1.13), and PDF 1.4 has no +/// `UserUnit`: a PDF/A-2b file at PDF 1.4 with a page longer than 14,400 pt is written as PDF 1.6, +/// with a `UserUnit` that keeps the page within 14,400 units and a warning, with +/// `long_pages_in_points` on or off. Pages past 32,767 pt that aren't whole numbers of points are +/// written too. A file whose pages are all 14,400 pt or shorter stays PDF 1.4. +#[test] +fn pdfa2b_at_pdf_1_4_writes_a_long_page_as_pdf_1_6_within_14400_units() { + for in_points in [false, true] { + let set = PdfSettings { + standard: Standard::PdfA2b, + compatibility: Compatibility::Pdf14, + long_pages_in_points: in_points, + ..PdfSettings::default() + }; + for side in [17_000.0, 32_767.0, 32_767.5, 40_000.0, TWENTY_M] { + let r = report(&sized(side, 300.0), set.clone()).unwrap_or_else(|e| panic!("{side} {in_points}: {e}")); + assert!(r.bytes.starts_with(b"%PDF-1.6"), "{side} {in_points}"); + let unit = raw_numbers(&r.bytes, "/UserUnit", 1); + assert_eq!(unit.len(), 1, "{side} {in_points}"); + let media = raw_numbers(&r.bytes, "/MediaBox", 4); + assert!(media[2] - media[0] <= 14_400.0 && media[3] - media[1] <= 14_400.0, "{side} {in_points}: {media:?}"); + assert!(((media[2] - media[0]) * unit[0] - side).abs() < side * 0.001, "{side} {in_points}: {media:?} {unit:?}"); + let raised = "PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6"; + assert!(r.warnings.iter().any(|w| w == raised), "{side} {in_points}: {:?}", r.warnings); + } + let r = report(&sized(14_400.0, 300.0), set.clone()).unwrap(); + assert!(r.bytes.starts_with(b"%PDF-1.4"), "{in_points}"); + assert!(!r.warnings.iter().any(|w| w.contains("has no UserUnit")), "{in_points}: {:?}", r.warnings); + } } /// A page from 14,400 to 32,767 pt long is written in points, with the warning, in PDF 1.3 and 1.4 -/// files, PDF/X-1a, PDF/X-3, PDF/X-4 and PDF/A-2b files among them, as before the warning. Past +/// files, PDF/X-1a, PDF/X-3 and PDF/X-4 files among them, as before the warning. Past /// 32,767 pt a page of whole points is written too, as the writer puts whole numbers as integers; /// a side that isn't a whole number, or art that needs a real number past 32,767, gets the error. #[test] @@ -276,7 +310,6 @@ fn pdf_1_4_writes_a_page_up_to_32767_pt_in_points() { ("PDF/X-1a:2001", preset("PDF/X-1a:2001")), ("PDF/X-3:2002", preset("PDF/X-3:2002")), ("PDF/X-4:2010", PdfSettings { standard: Standard::PdfX4, compatibility: Compatibility::Pdf14, ..PdfSettings::default() }), - ("PDF/A-2b", PdfSettings { standard: Standard::PdfA2b, compatibility: Compatibility::Pdf14, ..PdfSettings::default() }), ]; for (label, set) in &sets { for side in [17_000.0, 32_767.0, 40_000.0] { @@ -287,8 +320,8 @@ fn pdf_1_4_writes_a_page_up_to_32767_pt_in_points() { let points = format!("{label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in"); assert!(w.len() == 1 && w[0].ends_with(&points), "{label}: {w:?}"); } - // For PDF/X-4 and PDF/A-2b files the error names the PDF version. - let label = if matches!(*label, "PDF/X-4:2010" | "PDF/A-2b") { "PDF 1.4" } else { label }; + // For PDF/X-4 files the error names the PDF version. + let label = if *label == "PDF/X-4:2010" { "PDF 1.4" } else { label }; let refused = |e: PdfError, side: &str| { let refusal = format!("{label} files hold real numbers up to 32,767, and the page of artboard 1, {side} pt long, needs larger ones"); assert!(e.to_string().contains(&refusal), "{e}"); diff --git a/crates/render/src/tests/longstroke.rs b/crates/render/src/tests/longstroke.rs index 8a6567f39..dc16e6430 100644 --- a/crates/render/src/tests/longstroke.rs +++ b/crates/render/src/tests/longstroke.rs @@ -92,6 +92,11 @@ fn a_long_profile_stroke_keeps_its_widths_when_outlined_near_the_view() { let near = render(&mut renderer, &d, at); assert_eq!(renderer.strokes.values().any(|e| e.crop.is_some()), cropped, "{name}: crop choice"); let full = whole(&d, at); + // The cropped outline fits cubics through the same samples, but its fit starts at the + // crop rather than at the subpath's end, so a cubic may put its control points + // differently (the same curve). The rasteriser then flattens it into a slightly + // different polygon, which shows as one level on an anti-aliased edge pixel. Anything + // more would be a wrong width. let mismatched: Vec<_> = near .pixels .as_chunks::<4>() @@ -99,7 +104,7 @@ fn a_long_profile_stroke_keeps_its_widths_when_outlined_near_the_view() { .iter() .zip(full.pixels.as_chunks::<4>().0.iter()) .enumerate() - .filter(|(_, (a, b))| a != b) + .filter(|(_, (a, b))| a.iter().zip(b.iter()).any(|(a, b)| a.abs_diff(*b) > 1)) .take(8) .map(|(i, (a, b))| (i % SIZE as usize, i / SIZE as usize, a.to_vec(), b.to_vec())) .collect(); diff --git a/crates/tools/src/shape.rs b/crates/tools/src/shape.rs index 163117085..6609ef62a 100644 --- a/crates/tools/src/shape.rs +++ b/crates/tools/src/shape.rs @@ -11,19 +11,20 @@ //! The selection keeps its bounding box and its Live Shape widgets on show with the Rectangle, //! Rounded Rectangle, Ellipse, Polygon and Star tools, so a shape just drawn can be adjusted at //! once: a drag on a live rectangle's or polygon's corner widget rounds its corners and a drag on -//! a live ellipse's pie widget sets its pie angle, as with the Selection tool; a press on the -//! selection's centre point (Show Center) moves it, and one just outside a corner of its box turns -//! it about that centre, as the Selection tool's rotate zone does; a press anywhere else draws a -//! new shape (#5477). +//! a live ellipse's pie widget sets its pie angle, as with the Selection tool; a drag on one of the +//! box's eight handles scales the selection, a press on the selection's centre point (Show Center) +//! moves it, and one just outside a corner of its box turns it about that centre, as the Selection +//! tool's handles, centre point and rotate zone do; a press anywhere else draws a new shape (#5477). use serde_json::{Value, json}; +use vectorcraft_doc::OrientedBox; use vectorcraft_geom::{Affine, Point, Rect, Vec2}; -use crate::bbox::{move_delta, rotate_for_drag}; +use crate::bbox::{Handle, move_delta, rotate_for_drag, scale_for_drag}; use crate::corners::{self, CornerDrag}; -use crate::guides::{DrawSnap, Leave, square}; +use crate::guides::{DrawSnap, Leave, PointSnap, Targets, square}; use crate::pie::{self, PieDrag}; -use crate::select::{MoveSnap, hit_center, matrix_json, rotate_zone, shown_box}; +use crate::select::{MoveSnap, handle_cursor, hit_center, is_area_type, matrix_json, rotate_zone, shown_box}; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; /// The largest corner radius the Rounded Rectangle tool draws, points: the range of General › @@ -31,6 +32,22 @@ use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, Tool /// value stop there too. pub const MAX_CORNER_RADIUS: f64 = 1296.0; +/// How far (screen points, along each axis) from a bounding-box handle's centre a shape tool's +/// press scales the selection: half the drawn handle square, which is 6 points wide at the default +/// Selection & Anchor Display › Size 3 and a point wider or narrower per step. The Selection tool +/// takes 5 points. +fn handle_reach(cx: &ToolContext) -> f64 { + (6.0 + f64::from(cx.anchor_size.clamp(1, 7)) - 3.0) / 2.0 +} + +/// A document offset `d` in screen points: across and down the window. +fn screen_offset(cx: &ToolContext, d: Vec2) -> (f64, f64) { + match cx.screen { + Some(s) if s.right.hypot2() > 0.0 && s.down.hypot2() > 0.0 => (d.dot(s.right) / s.right.hypot2(), d.dot(s.down) / s.down.hypot2()), + _ => (d.x * cx.zoom, d.y * cx.zoom), + } +} + /// Does tool `id` keep the selection's bounding box and Live Shape widgets on show and let its /// corner and pie widgets be dragged (#5477)? The shape tools that draw a box: not Line Segment. pub fn shows_shape_widgets(id: &str) -> bool { @@ -43,9 +60,15 @@ enum WidgetDrag { Pie(PieDrag), } -/// A drag of the selection itself, taken by a shape tool: the centre point moves it and the zone -/// just outside a corner of its box turns it, as the Selection tool's drags do (#5477). +/// A drag of the selection itself, taken by a shape tool: a handle of its box scales it, the centre +/// point moves it and the zone just outside a corner of its box turns it, as the Selection tool's +/// drags do (#5477). enum SelectionDrag { + /// Scaling the selection by `handle` of its box `bx`, as it was when the press came. `areas`: + /// area type is selected, and its frame resizes while its type keeps its size. `snap` holds + /// what the moved handle snaps to. `last`: where the last drag was, and whether Backtick was + /// down then. + Scale { handle: Handle, bx: OrientedBox, areas: bool, snap: Box, last: Option<(Point, bool)> }, /// Moving the selection by the centre point `hold`: `start` is where the press was, and the /// move has begun once the pointer left the drag threshold. Move { start: Point, hold: Point, began: bool, snap: Box }, @@ -178,6 +201,22 @@ impl ShapeTool { rotate_zone(cx, &self.box_of(cx)?, p) } + /// The handle of the selection's box under `p`, with the box: a press there scales the + /// selection, as with the Selection tool. A shape tool takes only the drawn handle square + /// ([`handle_reach`]), which stays square to the screen when the box or the view is turned, so + /// a press just beside a handle still draws a new shape. + fn over_handle(&self, cx: &ToolContext, p: Point) -> Option<(Handle, OrientedBox)> { + let bx = self.box_of(cx)?; + let reach = handle_reach(cx); + Handle::ALL + .into_iter() + .find(|h| { + let (x, y) = screen_offset(cx, p - bx.to_doc() * h.pos(bx.rect)); + x.abs() <= reach && y.abs() <= reach + }) + .map(|h| (h, bx)) + } + /// Is `p` over the centre point of a selected object (Attributes › Show Center), where a press /// moves the selection? The point shows whatever View › Show Bounding Box says, so the press /// takes it too. @@ -185,22 +224,35 @@ impl ShapeTool { shows_shape_widgets(self.id) && hit_center(cx, p, cx.point_tol()).is_some() } - /// A press on the selection's centre point or just outside a corner of its box: the drag moves - /// or turns the selection instead of drawing a new shape (#5477). A turn begins with the press, - /// as the Selection tool's does; a move begins once the pointer leaves the drag threshold. + /// A press on a handle of the selection's box, on its centre point or just outside a corner of + /// its box: the drag scales, moves or turns the selection instead of drawing a new shape + /// (#5477). The handles come first, as with the Selection tool. A scale or a turn begins with + /// the press, as the Selection tool's do; a move begins once the pointer leaves the drag + /// threshold. fn press_selection(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Option> { if !shows_shape_widgets(self.id) { return None; } - self.selection = match hit_center(cx, ev.pos, cx.point_tol()) { - Some((_, hold)) => Some(SelectionDrag::Move { start: ev.pos, hold, began: false, snap: Box::new(MoveSnap::new(cx)) }), - None => self.over_rotate(cx, ev.pos).map(|center| SelectionDrag::Rotate { center, start: ev.pos }), + self.selection = if let Some((handle, bx)) = self.over_handle(cx, ev.pos) { + let is_area = |id: &vectorcraft_doc::NodeId| cx.doc.node(*id).is_some_and(is_area_type); + let areas = cx.selection.objects.iter().any(is_area); + Some(SelectionDrag::Scale { handle, bx, areas, snap: Box::new(PointSnap::new(cx, || Targets::for_scale(cx))), last: None }) + } else { + match hit_center(cx, ev.pos, cx.point_tol()) { + Some((_, hold)) => Some(SelectionDrag::Move { start: ev.pos, hold, began: false, snap: Box::new(MoveSnap::new(cx)) }), + None => self.over_rotate(cx, ev.pos).map(|center| SelectionDrag::Rotate { center, start: ev.pos }), + } }; self.selection.as_ref()?; self.snap.clear(); self.guides.clear(); self.measure = None; Some(match self.selection { + // Only area type: the step resizes type areas; anything else scales. + Some(SelectionDrag::Scale { areas, .. }) => { + let only_areas = areas && cx.selection.objects.iter().all(|id| cx.doc.node(*id).is_some_and(is_area_type)); + vec![Action::Begin(if only_areas { "Resize Type Area" } else { "Scale" }.into())] + } Some(SelectionDrag::Rotate { .. }) => vec![Action::Begin("Rotate".into())], _ => vec![], }) @@ -235,6 +287,32 @@ impl ShapeTool { self.measure = cx.transform_tools_guides.then(|| (ev.pos, format!("{:.1}°", -deg))); vec![Action::Preview("object.transform".into(), json!({"matrix": matrix_json(a), "copy": false}))] } + (PointerKind::Drag, SelectionDrag::Scale { handle, bx, areas, snap, last }) => { + // As the Selection tool scales: in the box's own frame, the moved handle snapped, + // and with Use Preview Bounds by the visual box's size unless the drag flips it. + *last = Some((ev.pos, ev.backquote)); + let m = ev.mods; + let a = scale_for_drag(bx.rect, *handle, bx.to_local(ev.pos), m.shift, m.alt); + let (a, guides) = snap.snap_scale(cx, bx, *handle, a, m.shift, m.alt); + self.guides = guides; + let nr = a.transform_rect_bbox(bx.rect); + self.measure = cx.transform_tools_guides.then(|| (ev.pos, cx.size_label(nr.width(), nr.height()))); + let [sx, _, _, sy, _, _] = a.as_coeffs(); + let (cmd, mut params) = if !ev.backquote && cx.preview_bounds && sx > 0.0 && sy > 0.0 { + let reference = if m.alt { 4 } else { handle.opposite().reference() }; + ("object.setBounds", json!({ "width": nr.width(), "height": nr.height(), "reference": reference, "copy": false })) + } else { + ("object.transform", json!({ "matrix": matrix_json(bx.conjugate(a)), "copy": false })) + }; + if *areas { + params["typeAreas"] = json!(true); + } + if ev.backquote { + params["objects"] = json!(false); + params["patterns"] = json!(true); + } + vec![Action::Preview(cmd.into(), params)] + } (PointerKind::Up, SelectionDrag::Move { began, .. }) => { let began = *began; self.selection = None; @@ -243,8 +321,22 @@ impl ShapeTool { self.snapped = false; if began { vec![Action::Commit] } else { vec![] } } + (PointerKind::Up, SelectionDrag::Scale { last, .. }) => { + // Backtick pressed or let go after the last drag: the scale is previewed again at + // that drag's position with the key as it is at the release, as the Selection tool + // does. + let again = last.filter(|(_, backquote)| *backquote != ev.backquote).map(|(pos, _)| pos); + let mut out = + again.and_then(|pos| self.selection_pointer(cx, &PointerEvent { kind: PointerKind::Drag, pos, ..*ev })).unwrap_or_default(); + self.selection = None; + self.guides.clear(); + self.measure = None; + out.push(Action::Commit); + out + } (PointerKind::Up, SelectionDrag::Rotate { .. }) => { self.selection = None; + self.guides.clear(); self.measure = None; vec![Action::Commit] } @@ -422,8 +514,9 @@ impl Tool for ShapeTool { fn busy(&self) -> bool { self.start.is_some() || self.widget.is_some() || self.selection.is_some() } - /// A widget or selection drag takes the pointer; otherwise a press draws, unless it lands on - /// the selection's centre point or in the rotate zone just outside its box's corner. + /// A widget or selection drag takes the pointer; otherwise a press draws, unless it lands on a + /// handle of the selection's box, its centre point or the rotate zone just outside its box's + /// corner. fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { if let Some(out) = self.widget_pointer(cx, ev) { return out; @@ -434,7 +527,11 @@ impl Tool for ShapeTool { match ev.kind { PointerKind::Move => { // Over a widget a press would drag, no Smart Guide for a new shape's start. - if self.over_widget(cx, ev.pos) || self.over_center(cx, ev.pos) || self.over_rotate(cx, ev.pos).is_some() { + if self.over_widget(cx, ev.pos) + || self.over_handle(cx, ev.pos).is_some() + || self.over_center(cx, ev.pos) + || self.over_rotate(cx, ev.pos).is_some() + { self.snap.clear(); } else { self.snap.hover(cx, ev.pos, &[], None); @@ -566,6 +663,11 @@ impl Tool for ShapeTool { vec![] } } + /// A drag of a handle of the selection's box: the canvas keeps drawing the box at the changing + /// bounds, as for the Selection tool. + fn resizing(&self) -> bool { + matches!(self.selection, Some(SelectionDrag::Scale { .. })) + } /// Adjusting a widget (the canvas hides the bounding box meanwhile, as for the Selection tool). fn transforming(&self) -> bool { match &self.widget { @@ -573,7 +675,7 @@ impl Tool for ShapeTool { Some(WidgetDrag::Pie(d)) => d.adjusting(), None => match &self.selection { Some(SelectionDrag::Move { began, .. }) => *began, - Some(SelectionDrag::Rotate { .. }) => true, + Some(SelectionDrag::Rotate { .. } | SelectionDrag::Scale { .. }) => true, None => false, }, } @@ -630,9 +732,9 @@ impl Tool for ShapeTool { } o } - /// The Selection tool's pointer over a Live Shape widget, the selection's centre point or the - /// rotate zone just outside its box's corner; the crosshair everywhere else, where a press - /// draws. + /// The Selection tool's pointer over a Live Shape widget, a handle of the selection's box, its + /// centre point or the rotate zone just outside its box's corner; the crosshair everywhere + /// else, where a press draws. fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor { // Over a widget a press would drag (or dragging one), the Selection tool's pointer for it. let idle = self.start.is_none(); @@ -645,13 +747,14 @@ impl Tool for ShapeTool { // move snaps, as the Selection tool's. match &self.selection { Some(SelectionDrag::Rotate { center, .. }) => return Cursor::rotate_about_in_view(*center, p, cx.view_angle()), + Some(SelectionDrag::Scale { handle, bx, .. }) => return handle_cursor(*handle, bx.angle - cx.view_angle()), Some(SelectionDrag::Move { began: true, .. }) if m.alt => return Cursor::SelectionCopy, Some(SelectionDrag::Move { began: true, .. }) => return if self.snapped { Cursor::ArrowHollow } else { Cursor::Arrow }, Some(_) => return Cursor::Arrow, None => {} } - // A press takes the corner and pie widgets first, then the centre point, then the zone - // just outside a corner; everywhere else it draws a new shape. + // A press takes the corner and pie widgets first, then the box's handles, then the centre + // point, then the zone just outside a corner; everywhere else it draws a new shape. if idle { if shows_shape_widgets(self.id) && corners::over_widget(cx, p, false) { return Cursor::CornerRadius; @@ -659,6 +762,9 @@ impl Tool for ShapeTool { if self.over_widget(cx, p) { return Cursor::ArrowHollow; } + if let Some((handle, bx)) = self.over_handle(cx, p) { + return handle_cursor(handle, bx.angle - cx.view_angle()); + } if self.over_center(cx, p) { return Cursor::Move; } diff --git a/crates/tools/src/shape/tests.rs b/crates/tools/src/shape/tests.rs index 580f15c16..663cdab3f 100644 --- a/crates/tools/src/shape/tests.rs +++ b/crates/tools/src/shape/tests.rs @@ -583,6 +583,96 @@ fn a_shape_tool_moves_the_selection_by_its_centre_point_and_turns_it_from_a_corn assert_eq!(a.first(), Some(&Action::Begin("Rectangle".into())), "{a:?}"); } +/// With a shape tool, a drag on one of the eight handles of the selection's bounding box scales the +/// selection, as with the Selection tool, instead of drawing a new shape from the handle. +#[test] +fn a_shape_tool_scales_the_selection_by_its_bounding_box_handles() { + let (d, s) = unfilled_live_rect(); + let p = paint(); + let c = cx(&d, &s, &p); + let corner = Point::new(200.0, 200.0); + for id in ["rectangle", "roundedRectangle", "polygon", "star", "ellipse"] { + let mut t = ShapeTool::new(id); + assert_eq!(t.cursor(&c, corner, Mods::default()), handle_cursor(Handle::BottomRight, 0.0), "{id}"); + // No Smart Guide for a new shape's start over a handle. + t.pointer(&c, &PointerEvent::new(PointerKind::Move, corner.x, corner.y)); + assert!(t.overlays(&c).is_empty(), "{id}"); + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, corner.x, corner.y)), vec![Action::Begin("Scale".into())], "{id}"); + // A resize: the canvas keeps the box on show at the changing bounds. + assert!(t.busy() && t.transforming() && t.resizing(), "{id}"); + let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 220.0, 230.0)); + let want = scale_for_drag(Rect::new(100.0, 100.0, 200.0, 200.0), Handle::BottomRight, Point::new(220.0, 230.0), false, false); + assert_eq!(a, vec![Action::Preview("object.transform".into(), json!({"matrix": matrix_json(want), "copy": false}))], "{id}"); + assert_eq!(t.cursor(&c, Point::new(220.0, 230.0), Mods::default()), handle_cursor(Handle::BottomRight, 0.0), "{id}"); + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 220.0, 230.0)), vec![Action::Commit], "{id}"); + assert!(!t.busy() && !t.transforming() && !t.resizing(), "{id}"); + } + // The side handles scale along one axis; Escape turns the scale back. + let mut t = ShapeTool::new("rectangle"); + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 100.0)), vec![Action::Begin("Scale".into())]); + let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 150.0, 80.0)); + let want = scale_for_drag(Rect::new(100.0, 100.0, 200.0, 200.0), Handle::Top, Point::new(150.0, 80.0), false, false); + assert_eq!(a, vec![Action::Preview("object.transform".into(), json!({"matrix": matrix_json(want), "copy": false}))]); + assert_eq!(t.key(&c, ToolKey::Escape, Mods::default()), vec![Action::Cancel]); + assert!(!t.busy()); + // Backtick pressed after the last drag: the release previews the scale again for the patterns + // only, then commits. + let mut t = ShapeTool::new("rectangle"); + t.pointer(&c, &PointerEvent::new(PointerKind::Down, corner.x, corner.y)); + t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 220.0, 230.0)); + let a = t.pointer(&c, &PointerEvent { backquote: true, ..PointerEvent::new(PointerKind::Up, 220.0, 230.0) }); + let want = scale_for_drag(Rect::new(100.0, 100.0, 200.0, 200.0), Handle::BottomRight, Point::new(220.0, 230.0), false, false); + let patterns = json!({"matrix": matrix_json(want), "copy": false, "objects": false, "patterns": true}); + assert_eq!(a, vec![Action::Preview("object.transform".into(), patterns), Action::Commit]); + assert!(!t.busy()); + // View › Hide Bounding Box hides the handles, and a press there draws a new shape. + let hidden = ToolContext { show_bbox: false, ..cx(&d, &s, &p) }; + let mut t = ShapeTool::new("rectangle"); + assert_eq!(t.cursor(&hidden, corner, Mods::default()), Cursor::Crosshair); + t.pointer(&hidden, &PointerEvent::new(PointerKind::Down, corner.x, corner.y)); + let a = t.pointer(&hidden, &PointerEvent::new(PointerKind::Drag, 240.0, 240.0)); + assert_eq!(a.first(), Some(&Action::Begin("Rectangle".into())), "{a:?}"); + // The Line Segment tool shows no box, so its press draws. + let mut t = ShapeTool::new("lineSegment"); + t.pointer(&c, &PointerEvent::new(PointerKind::Down, corner.x, corner.y)); + let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 240.0, 240.0)); + assert_eq!(a.first(), Some(&Action::Begin("Line".into())), "{a:?}"); +} + +/// A shape tool's press scales the selection only on the drawn handle square: 3 screen points from +/// the handle's centre along each axis at the default Anchor Size, at any zoom. A press 4 points +/// from it draws a new shape; at Anchor Size 7 the square is 10 points wide and that press scales. +#[test] +fn a_shape_tool_draws_from_a_press_just_beside_a_handle() { + let (d, s) = unfilled_live_rect(); + let p = paint(); + for zoom in [1.0, 4.0] { + let c = ToolContext { zoom, ..cx(&d, &s, &p) }; + let on = Point::new(200.0 + 3.0 / zoom, 200.0 - 3.0 / zoom); + let mut t = ShapeTool::new("rectangle"); + assert_eq!(t.cursor(&c, on, Mods::default()), handle_cursor(Handle::BottomRight, 0.0), "{zoom}"); + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, on.x, on.y)), vec![Action::Begin("Scale".into())], "{zoom}"); + let beside = Point::new(200.0 + 2.0 / zoom, 200.0 + 4.0 / zoom); + let mut t = ShapeTool::new("rectangle"); + assert_eq!(t.cursor(&c, beside, Mods::default()), Cursor::Crosshair, "{zoom}"); + t.pointer(&c, &PointerEvent::new(PointerKind::Down, beside.x, beside.y)); + let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 260.0, 260.0)); + assert_eq!(a.first(), Some(&Action::Begin("Rectangle".into())), "{zoom}: {a:?}"); + let big = ToolContext { anchor_size: 7, ..c }; + let mut t = ShapeTool::new("rectangle"); + assert_eq!(t.cursor(&big, beside, Mods::default()), handle_cursor(Handle::BottomRight, 0.0), "{zoom}"); + assert_eq!(t.pointer(&big, &PointerEvent::new(PointerKind::Down, beside.x, beside.y)), vec![Action::Begin("Scale".into())], "{zoom}"); + } + // In a view turned 45°, the drawn square stays square to the screen: a press 3 screen points + // right of and below the handle's centre, 4.24 pt along the box's x axis, scales. + let screen = turned_view(45.0); + let c = ToolContext { screen, ..cx(&d, &s, &p) }; + let f = screen.unwrap_or_else(|| panic!("a turned view")); + let corner = Point::new(200.0, 200.0) + f.right * 3.0 + f.down * 3.0; + let mut t = ShapeTool::new("rectangle"); + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, corner.x, corner.y)), vec![Action::Begin("Scale".into())]); +} + /// Two selected live rectangles, the second placed so that its top-left Live Corners widget (10 pt /// in along the corner's bisector) lands on the first one's centre point. fn two_live_rects() -> (vectorcraft_doc::Document, Selection) { diff --git a/crates/ui-egui/src/addons.rs b/crates/ui-egui/src/addons.rs index 6cacf1c0d..ced5eb33a 100644 --- a/crates/ui-egui/src/addons.rs +++ b/crates/ui-egui/src/addons.rs @@ -7,6 +7,7 @@ pub mod compounds; pub mod contour; pub mod document; pub mod enclosing_lasso; +pub mod image; pub mod pathtools; pub mod pen; pub mod select; @@ -26,6 +27,7 @@ pub fn plugin_menu() -> Vec { Item::Sub("Contour", contour::menu()), Item::Sub("Document Tools", document::menu()), Item::Sub("Enclosing Lasso", enclosing_lasso::menu()), + Item::Sub("Image Tools", image::menu()), Item::Sub("Path Precision Tools", pathtools::menu()), Item::Sub("Selection Tools", select::menu()), Item::Sub("Smart Pen", pen::menu()), diff --git a/crates/ui-egui/src/addons/image.rs b/crates/ui-egui/src/addons/image.rs new file mode 100644 index 000000000..f8aa72b8a --- /dev/null +++ b/crates/ui-egui/src/addons/image.rs @@ -0,0 +1,26 @@ +//! Image Tools in the interface: its submenu (Object › Plug-ins › Image Tools). Remove Background's +//! menu item opens the generic command dialog (`ui.paramDialog`) on its options, with a Preview +//! (`dialogs::command`). + +use serde_json::json; + +use crate::menus::Item; + +/// Remove Background's command. +pub const REMOVE_BACKGROUND: &str = "addon.image.removeBackground"; +/// Remove Background › Background: what the command's `background` takes, by name. +pub const BACKGROUNDS: &[(&str, &str)] = &[("Edge Color", "edges"), ("Corner Color", "corners"), ("Picked Colors", "colors")]; +/// Remove Background › Match: what the command's `match` takes, by name. +pub const MATCHES: &[(&str, &str)] = &[("Distance", "distance"), ("Each Channel", "channels")]; + +/// Object › Plug-ins › Image Tools. +pub fn menu() -> Vec { + vec![Item::Cmd( + "Remove Background…", + REMOVE_BACKGROUND, + json!({"background": "edges", "colors": "#ffffff", "contiguous": true, "match": "distance", "tolerance": 10, "softness": 10}), + )] +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui-egui/src/addons/image/tests.rs b/crates/ui-egui/src/addons/image/tests.rs new file mode 100644 index 000000000..349c35533 --- /dev/null +++ b/crates/ui-egui/src/addons/image/tests.rs @@ -0,0 +1,111 @@ +use std::sync::Arc; + +use serde_json::json; +use vectorcraft_doc::NodeKind; +use vectorcraft_engine::Session; + +use crate::tests_labels::painted_text; +use crate::{VectorcraftApp, menus}; + +/// An app with one image open and selected: 12 × 10 pixels, white, a near-white ring one pixel in +/// (13.7 % from white) and a red block inside it. +fn app() -> VectorcraftApp { + let img = image::RgbaImage::from_fn(12, 10, |x, y| { + image::Rgba(if (3..9).contains(&x) && (3..7).contains(&y) { + [200, 0, 0, 255] + } else if (1..11).contains(&x) && (1..9).contains(&y) && (x == 1 || x == 10 || y == 1 || y == 8) { + [220, 220, 220, 255] + } else { + [255; 4] + }) + }); + let mut png = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("document.open", json!({"name": "art.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap(); + app.run("select.all", json!({})).unwrap(); + app +} + +/// The selected image's pixel key and bytes. +fn image(app: &VectorcraftApp) -> (String, Arc>) { + let st = app.session.doc().unwrap(); + let Some(NodeKind::Image(im)) = st.selection.objects.first().and_then(|id| st.doc.node(*id)).map(|n| &n.kind) else { panic!("an image") }; + (im.key.clone(), st.doc.images[&im.key].bytes.clone()) +} + +/// The alpha of the selected image's pixels at the corner, on the ring and in the block. +fn alphas(app: &VectorcraftApp) -> [u8; 3] { + let px = image::load_from_memory(&image(app).1).unwrap().to_rgba8(); + [(0, 0), (1, 1), (4, 4)].map(|(x, y)| px.get_pixel(x, y).0[3]) +} + +fn open(app: &mut VectorcraftApp) { + let entries = menus::menu_entries(app); + let item = entries.iter().find(|e| e.path == ["Object", "Plug-ins", "Image Tools"] && e.label == "Remove Background…").unwrap(); + assert!(item.enabled); + let (id, p) = menus::click_target(&item.label, item.command.as_deref().unwrap(), &item.params); + menus::invoke(app, &id, p); +} + +fn show(app: &mut VectorcraftApp) -> String { + painted_text(app, |app, ui| crate::dialogs::show(app, ui.ctx())) +} + +fn set(app: &mut VectorcraftApp, key: &str, v: serde_json::Value) { + crate::dialogs::set_field(app.ui.dialog.as_mut().unwrap(), key.into(), v); +} + +/// Object › Plug-ins › Image Tools › Remove Background… opens the command dialog on Edge Color +/// with Preview off. With Preview on the canvas shows the result for the values in the dialog, +/// each change worked out again from the image as it was; Cancel puts the image back with no step +/// of history, and OK keeps the result for the values in the dialog as one step. +#[test] +fn remove_background_previews_cancels_and_runs_on_ok() { + let mut app = app(); + let steps = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len(); + let (start, n) = (image(&app), steps(&app)); + + open(&mut app); + let d = app.ui.dialog.as_ref().expect("a dialog opens"); + assert_eq!((d.str("__command"), d.str("__label")), (super::REMOVE_BACKGROUND.to_string(), "Remove Background".to_string())); + assert_eq!((d.str("background"), d.bool("preview")), ("edges".to_string(), false)); + assert!(crate::dialogs::accepts_field(d, "preview")); + assert_eq!((d.bool("contiguous"), d.str("match")), (true, "distance".to_string())); + let shown = show(&mut app); + for s in ["Remove Background", "Edge Color", "Contiguous", "Match", "Distance", "Tolerance", "Softness", "Preview"] { + assert!(shown.contains(s), "{s} in {shown}"); + } + assert_eq!(image(&app), start, "no preview without the checkbox"); + + // Preview on: at tolerance 10 the ring fades; at 20 it is cleared; at 0, with no softness, + // only the white is cleared and the ring is whole again. + set(&mut app, "preview", json!(true)); + show(&mut app); + let [corner, ring, block] = alphas(&app); + assert!(corner == 0 && ring > 0 && ring < 255 && block == 255, "{:?}", alphas(&app)); + set(&mut app, "tolerance", json!(20)); + show(&mut app); + assert_eq!(alphas(&app), [0, 0, 255]); + set(&mut app, "tolerance", json!(0)); + set(&mut app, "softness", json!(0)); + show(&mut app); + assert_eq!(alphas(&app), [0, 255, 255], "worked out again from the image as it was"); + + // Cancel: as it was, with no step of history. + crate::dialogs::cancel(&mut app); + assert_eq!((image(&app), steps(&app)), (start.clone(), n), "Cancel restores the image and adds no undo step"); + assert!(!app.session.in_interaction()); + + // OK: the values in the dialog, as one step. + open(&mut app); + set(&mut app, "preview", json!(true)); + set(&mut app, "tolerance", json!(20)); + show(&mut app); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(steps(&app), n + 1, "one undo step"); + assert_eq!(alphas(&app), [0, 0, 255]); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(image(&app), start); +} diff --git a/crates/ui-egui/src/canvas/overlays.rs b/crates/ui-egui/src/canvas/overlays.rs index 8570b8744..8ff6faaf7 100644 --- a/crates/ui-egui/src/canvas/overlays.rs +++ b/crates/ui-egui/src/canvas/overlays.rs @@ -123,7 +123,7 @@ pub(super) fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: // shows while the key is down, where its handles take that press. let held = super::drag::mods(p.ctx().input(|i| i.modifiers), false); let lends_selection = !app.session.tool_busy() && app.session.lent_tool_id(&held) == Some("selection"); - let show_box = (app.session.tool_id() == "selection" || lends_selection || (shape_tool && !app.session.tool_busy())) + let show_box = (app.session.tool_id() == "selection" || lends_selection || (shape_tool && (!app.session.tool_busy() || resizing))) && (resizing || !app.session.tool_transforming() || moved.is_some()) && app.ui.view.bounding_box && app.session.active().is_some_and(|st| !st.selection.is_empty() && st.selection.anchors.is_empty()); diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 1e83c87ec..4f962fdb5 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -1173,6 +1173,56 @@ fn a_click_just_outside_the_canvas_does_not_reach_the_tool() { assert_eq!(selected(&app), vec![vectorcraft_doc::NodeId(id)]); } +/// The box-drawing shape tools show the bounding box of the shape just drawn (#5477). A drag of +/// its bottom-right handle with the tool still active scales that shape, as with the Selection +/// tool, rather than drawing a second shape from the handle. +#[test] +fn a_shape_tool_scales_the_shape_just_drawn_by_its_bounding_box_handle() { + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + for tool in ["rectangle", "roundedRectangle", "ellipse", "polygon", "star"] { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.select_tool(tool); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let drag = |app: &mut VectorcraftApp, a: Pos2, b: Pos2| { + for ev in [ + egui::Event::PointerMoved(a), + button(a, true), + egui::Event::PointerMoved(a.lerp(b, 0.5)), + egui::Event::PointerMoved(b), + button(b, false), + ] { + frame(app, &ctx, vec![ev]); + } + frame(app, &ctx, vec![]); + }; + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + drag(&mut app, xf.to_screen(Point::new(150.0, 120.0)), xf.to_screen(Point::new(210.0, 170.0))); + let objects = |app: &VectorcraftApp| { + let mut n = 0; + app.session.active().unwrap().doc.walk(|node| n += usize::from(node.path_data().is_some())); + n + }; + assert_eq!(objects(&app), 1, "{tool}: the drag draws one shape"); + let before = app.selection_box().unwrap_or_else(|| panic!("{tool}: the shape just drawn shows its box")); + // The bottom-right handle, dragged 40 pt along the box's width and 30 pt along its height + // (a polygon's or star's box turns with the shape). + let (corner, to) = (Point::new(before.rect.x1, before.rect.y1), Point::new(before.rect.x1 + 40.0, before.rect.y1 + 30.0)); + drag(&mut app, xf.to_screen(before.to_doc() * corner), xf.to_screen(before.to_doc() * to)); + let after = app.selection_box().unwrap_or_else(|| panic!("{tool}: the shape stays selected")); + let label = app.session.active().and_then(|st| st.history.undo.last().map(|e| e.label.clone())); + assert_eq!(objects(&app), 1, "{tool}: no second shape, last step {label:?}"); + assert!( + (after.rect.width() - before.rect.width() - 40.0).abs() < 0.5 && (after.rect.height() - before.rect.height() - 30.0).abs() < 0.5, + "{tool}: {:?} -> {:?}, last step {label:?}", + before.rect, + after.rect + ); + assert_eq!(label.as_deref(), Some("Scale"), "{tool}"); + } +} + #[test] fn a_drag_from_a_ruler_onto_the_canvas_makes_a_guide() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); diff --git a/crates/ui-egui/src/chrome/control_bar.rs b/crates/ui-egui/src/chrome/control_bar.rs index 2bdb2cb64..53bfb98c1 100644 --- a/crates/ui-egui/src/chrome/control_bar.rs +++ b/crates/ui-egui/src/chrome/control_bar.rs @@ -180,7 +180,8 @@ fn inline_transform_width(ui: &Ui, t: &Tokens) -> f32 { labels + fields + crate::panels::transform::CONSTRAIN_LINK_SIZE + ui.spacing().item_spacing.x * items as f32 } -/// The size of the Control bar's Align, Distribute and area type vertical alignment buttons. +/// The size of the Control bar's Align, Distribute and area type vertical alignment buttons, and +/// the height of its Align To menu. const BUTTON: f32 = 24.0; /// The space ahead of the separator that ends a group of the Control bar's controls. @@ -219,9 +220,9 @@ fn separator_width(ui: &Ui) -> f32 { /// The room the controls at the end of the Control bar need: the Transform link, or Document Setup /// and Preferences with nothing selected, or none while the panel menu has that group off. The -/// groups ahead of them (the Paragraph section, area type's vertical alignment, the Align and -/// Distribute buttons) show only while it is left, so the bar trims them from its right end -/// (#5372), after the inline X/Y/W/H fields. +/// groups ahead of them (the Paragraph section, area type's vertical alignment, the Align buttons, +/// the Align To menu and the Distribute buttons) show only while it is left, so the bar trims them +/// from its right end (#5372), after the inline X/Y/W/H fields. fn end_width(app: &VectorcraftApp, ui: &Ui, nothing_selected: bool) -> f32 { if nothing_selected { if group_on(app, "documentOptions") { DOCUMENT_SETUP_WIDTH + ui.spacing().item_spacing.x + PREFERENCES_WIDTH } else { 0.0 } @@ -235,10 +236,10 @@ fn end_width(app: &VectorcraftApp, ui: &Ui, nothing_selected: bool) -> f32 { /// Draws the Control bar's groups between its Character controls and the Transform link, and /// returns whether the Transform link and X/Y/W/H follow (false: nothing is selected, and Document /// Setup and Preferences, drawn here, end the bar, or the Transform group is off). The groups are -/// the Paragraph section and area type's vertical alignment (#3759), then the Align buttons and, for -/// two or more objects, the -/// Distribute buttons (a single object keeps the bar as it was); each shows only while the end of -/// the bar keeps its room. Groups the panel menu has off are left out, and take no room. +/// the Paragraph section and area type's vertical alignment (#3759), then the Align buttons, the +/// Align To menu and, for two or more objects, the Distribute buttons (a single object keeps the bar +/// as it was); each shows only while the end of the bar keeps its room. Groups the panel menu has +/// off are left out, and take no room. fn groups_before_transform(app: &mut VectorcraftApp, ui: &mut Ui, objects: usize) -> bool { let nothing = objects == 0; let end = end_width(app, ui, nothing); @@ -267,9 +268,12 @@ fn groups_before_transform(app: &mut VectorcraftApp, ui: &mut Ui, objects: usize let end = if transform { separator_width(ui) + end } else { 0.0 }; if group_on(app, "alignDistribute") && ui.available_width() >= buttons_width(ui, 7) + end { crate::panels::align::align_buttons(app, ui, BUTTON); - if objects > 1 && ui.available_width() >= GROUP_GAP + buttons_width(ui, 6) + end { - ui.add_space(GROUP_GAP); - crate::panels::align::distribute_buttons(app, ui, BUTTON); + if ui.available_width() >= crate::panels::align::align_to_menu_width(BUTTON) + ui.spacing().item_spacing.x + end { + crate::panels::align::align_to_menu(app, ui, BUTTON); + if objects > 1 && ui.available_width() >= GROUP_GAP + buttons_width(ui, 6) + end { + ui.add_space(GROUP_GAP); + crate::panels::align::distribute_buttons(app, ui, BUTTON); + } } if transform { separator(ui); diff --git a/crates/ui-egui/src/chrome/tests.rs b/crates/ui-egui/src/chrome/tests.rs index 2d18c3ddd..d7620e8d0 100644 --- a/crates/ui-egui/src/chrome/tests.rs +++ b/crates/ui-egui/src/chrome/tests.rs @@ -2722,6 +2722,109 @@ fn the_control_bar_shows_the_distribute_buttons_for_two_or_more_objects() { assert_eq!((left(&app, &a), left(&app, &b), left(&app, &c)), (10.0, 105.0, 200.0), "{}", app.ui.status); } +/// The Control bar's Align To menu follows its Align buttons. It shows the Align panel's Align To +/// choice (Artboard for one object, which the buttons align to the artboard) and screen readers +/// get it as the value of a combo box named "Align To:". Its menu checks that choice, has Align to +/// Key Object off while no key object can be made, and a pick chooses as the Align panel does: +/// Artboard and Selection are stored with the UI state and let go of the key object, and Key +/// Object makes the topmost selected object the key, keeping the stored choice. The Align +/// buttons then align to the choice. +#[test] +fn the_control_bar_align_to_menu_chooses_what_align_aligns_to() { + crate::i18n::set_current(crate::i18n::Lang::EN); + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 200})).unwrap(); + let rect = |app: &mut crate::VectorcraftApp, x: f64| { + app.run("shape.rectangle", json!({"x": x, "y": 40, "width": 30, "height": 20})).unwrap()["id"].clone() + }; + let (a, b) = (rect(&mut app, 50.0), rect(&mut app, 150.0)); + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + crate::theme::install_fonts(&ctx); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 300.0)); + let frame = |app: &mut crate::VectorcraftApp, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { events, screen_rect: Some(screen), ..Default::default() }, |ui| super::control_bar(app, ui)); + out.textures_delta.clear(); + out.platform_output.accesskit_update.map(|u| u.nodes).unwrap_or_default() + }; + type Nodes = Vec<(egui::accesskit::NodeId, egui::accesskit::Node)>; + // A control by its name; a menu row's name follows its check column. + let find = |nodes: &Nodes, name: &str| { + nodes.iter().find(|(_, n)| n.label().is_some_and(|l| l.trim_start_matches(['✓', ' ']) == name)).map(|(_, n)| n.clone()) + }; + let center = |n: &egui::accesskit::Node| { + let b = n.bounds().unwrap_or_else(|| panic!("{n:?}")); + egui::pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32) + }; + let click = |app: &mut crate::VectorcraftApp, at: egui::Pos2| { + let press = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(app, vec![egui::Event::PointerMoved(at), press(true)]); + frame(app, vec![press(false)]); + frame(app, vec![]); + frame(app, vec![]) + }; + let menu = |app: &mut crate::VectorcraftApp| { + frame(app, vec![]); + let nodes = frame(app, vec![]); + find(&nodes, "Align To:").unwrap_or_else(|| panic!("the Control bar has the Align To menu")) + }; + // Opens the menu, checks its rows and picks `pick`, or closes it with Escape. + let choose = |app: &mut crate::VectorcraftApp, checked: &str, key_on: bool, pick: Option<&str>| { + let at = center(&menu(app)); + let nodes = click(app, at); + for name in ["Align to Selection", "Align to Key Object", "Align to Artboard"] { + let row = find(&nodes, name).unwrap_or_else(|| panic!("the menu lists {name}")); + assert_eq!(row.label().is_some_and(|l| l.starts_with('✓')), name == checked, "{name} checked"); + assert_eq!(row.is_disabled(), name == "Align to Key Object" && !key_on, "{name} off"); + } + let nodes = match pick { + Some(pick) => click(app, center(&find(&nodes, pick).unwrap_or_else(|| panic!("{pick}")))), + None => { + let escape = + |pressed| egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed, repeat: false, modifiers: Default::default() }; + frame(app, vec![escape(true)]); + frame(app, vec![escape(false)]); + frame(app, vec![]) + } + }; + assert!(find(&nodes, "Align to Artboard").is_none(), "the menu closes"); + }; + let key = |app: &crate::VectorcraftApp| app.session.active().unwrap().selection.key; + let x0 = |app: &crate::VectorcraftApp, id: &serde_json::Value| { + app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().geometric_bounds().unwrap().x0 + }; + + // One object: the buttons align it to the artboard, and Align to Key Object is off. + app.run("select.set", json!({"ids": [a]})).unwrap(); + let m = menu(&mut app); + assert_eq!(m.role(), egui::accesskit::Role::ComboBox); + assert_eq!(m.value(), Some("Align to Artboard"), "one object shows Artboard"); + choose(&mut app, "Align to Artboard", false, None); + assert_eq!(key(&app), None); + + // Two objects: the stored choice, Selection. + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + assert_eq!(menu(&mut app).value(), Some("Align to Selection")); + choose(&mut app, "Align to Selection", true, Some("Align to Artboard")); + assert_eq!(app.ui.align_to, crate::panels::align::AlignTo::Artboard, "the choice the Align panel shows"); + assert_eq!(menu(&mut app).value(), Some("Align to Artboard")); + let left = find(&frame(&mut app, vec![]), "Horizontal Align Left").unwrap_or_else(|| panic!("Horizontal Align Left")); + click(&mut app, center(&left)); + assert_eq!((x0(&app, &a), x0(&app, &b)), (0.0, 0.0), "aligned to the artboard: {}", app.ui.status); + + // Key Object makes the topmost the key and keeps the stored choice. + choose(&mut app, "Align to Artboard", true, Some("Align to Key Object")); + assert_eq!(key(&app), Some(vectorcraft_doc::NodeId(b.as_u64().unwrap())), "the topmost object is the key"); + assert_eq!(app.ui.align_to, crate::panels::align::AlignTo::Artboard, "the stored choice stays"); + assert_eq!(menu(&mut app).value(), Some("Align to Key Object")); + + // Selection lets go of the key and is stored. + choose(&mut app, "Align to Key Object", true, Some("Align to Selection")); + assert_eq!(key(&app), None, "the key object is let go"); + assert_eq!(app.ui.align_to, crate::panels::align::AlignTo::Selection); + assert_eq!(menu(&mut app).value(), Some("Align to Selection")); +} + /// The Control bar shows the Global Edit Options dropdown while Start Global Edit can run. Its /// options are the session's, which the Properties panel's dropdown also sets: Match Size from /// the Control bar narrows the Results and the objects Start Global Edit takes. The dropdown is off @@ -2824,7 +2927,8 @@ fn the_control_bar_shows_the_inline_transform_fields_only_when_they_fit() { crate::theme::install_fonts(&ctx); let mut seen = false; let mut menu_seen = false; - // The Align group, then for two objects the Distribute group (#5372): each by its controls. + // The Align group and the Align To menu, then for two objects the Distribute group (#5372): + // each by its controls. let mut groups: Vec<(&str, Vec<&str>)> = vec![( "Align", vec![ @@ -2837,6 +2941,7 @@ fn the_control_bar_shows_the_inline_transform_fields_only_when_they_fit() { "Vertical Align Bottom", ], )]; + groups.push(("Align To", vec!["Align To:"])); if count == 2 { groups.push(( "Distribute", diff --git a/crates/ui-egui/src/dialogs/char_style_options.rs b/crates/ui-egui/src/dialogs/char_style_options.rs index d1ca76cc5..54df0ab91 100644 --- a/crates/ui-egui/src/dialogs/char_style_options.rs +++ b/crates/ui-egui/src/dialogs/char_style_options.rs @@ -26,9 +26,9 @@ const NUMBERS: &[&str] = &["size", "leading", "kerning", "tracking"]; /// The on, off or unset checkboxes. const SWITCHES: &[&str] = &["underline", "strikethrough", "standardVerticalRoman"]; /// The attributes of Advanced Character Formats, in the order the section shows them. -const ADVANCED: &[&str] = &["h_scale", "v_scale", "baseline_shift", "rotation", "language"]; +const ADVANCED: &[&str] = &["h_scale", "v_scale", "baseline_shift", "tcyUpDown", "tcyLeftRight", "rotation", "language"]; /// The number fields of Advanced Character Formats. -const ADVANCED_NUMBERS: &[&str] = &["h_scale", "v_scale", "baseline_shift", "rotation"]; +const ADVANCED_NUMBERS: &[&str] = &["h_scale", "v_scale", "baseline_shift", "tcyUpDown", "tcyLeftRight", "rotation"]; /// The attributes of Character Color. const COLOR: &[&str] = &["fill", "stroke", "stroke_width", "overprint_fill", "overprint_stroke"]; /// The fields of Character Color: the fill and stroke paints, their tints in percent, the stroke @@ -78,6 +78,8 @@ pub(super) const SPEC: DialogSpec = DialogSpec { "h_scale", "v_scale", "baseline_shift", + "tcyUpDown", + "tcyLeftRight", "rotation", "language", "fill", @@ -250,6 +252,8 @@ fn optional_number(d: &Dialog, key: &str) -> Result, String> { "h_scale" => tl!("Horizontal Scale"), "v_scale" => tl!("Vertical Scale"), "baseline_shift" => tl!("Baseline Shift"), + "tcyUpDown" => tl!("Tate-chu-yoko Up/Down"), + "tcyLeftRight" => tl!("Tate-chu-yoko Left/Right"), "rotation" => tl!("Character Rotation"), "fill_tint" => tl!("Fill Tint"), "stroke_tint" => tl!("Stroke Tint"), @@ -281,7 +285,7 @@ fn optional_number(d: &Dialog, key: &str) -> Result, String> { if ["h_scale", "v_scale"].contains(&key) && !(1.0..=10000.0).contains(&n) { return Err(format!("{label} must be a number from 1 to 10000")); } - if key == "baseline_shift" && !(-1296.0..=1296.0).contains(&n) { + if ["baseline_shift", "tcyUpDown", "tcyLeftRight"].contains(&key) && !(-1296.0..=1296.0).contains(&n) { return Err(format!("{label} must be a number from -1296 to 1296")); } // Weight from 0 to the document limit, a tint from 0% to 100%. @@ -564,7 +568,7 @@ pub(crate) fn reset_basic(d: &mut Dialog) { } } -/// Reset Panel in Advanced Character Formats: its five attributes become unset. +/// Reset Panel in Advanced Character Formats: its seven attributes become unset. pub(crate) fn reset_advanced(d: &mut Dialog) { d.fields.insert("__resetAdvanced".into(), json!(true)); for key in ADVANCED_NUMBERS { @@ -718,14 +722,16 @@ pub(crate) fn basic_body(ui: &mut egui::Ui, d: &mut Dialog) { }); } -/// Advanced Character Formats: Horizontal Scale, Vertical Scale, Baseline Shift, Character Rotation -/// and Language, each empty (unset) or set. +/// Advanced Character Formats: Horizontal Scale, Vertical Scale, Baseline Shift, the Tate-chu-yoko +/// Up/Down and Left/Right, Character Rotation and Language, each empty (unset) or set. pub(crate) fn advanced_body(ui: &mut egui::Ui, d: &mut Dialog) { egui::Grid::new("character-style-advanced").num_columns(2).spacing([18.0, 8.0]).show(ui, |ui| { for (key, caption, unit) in [ ("h_scale", tl!("Horizontal Scale"), "%"), ("v_scale", tl!("Vertical Scale"), "%"), ("baseline_shift", tl!("Baseline Shift"), "pt"), + ("tcyUpDown", tl!("Tate-chu-yoko Up/Down"), "pt"), + ("tcyLeftRight", tl!("Tate-chu-yoko Left/Right"), "pt"), ("rotation", tl!("Character Rotation"), "°"), ] { let row = ui.label(caption); @@ -1473,11 +1479,19 @@ mod tests { } #[test] - fn advanced_section_shows_its_five_controls() { + fn advanced_section_shows_its_seven_controls() { let mut d = dialog_for(json!({})); let mut app = app(); let shown = crate::tests_labels::painted_text(&mut app, |_, ui| advanced_body(ui, &mut d)); - for label in ["Horizontal Scale", "Vertical Scale", "Baseline Shift", "Character Rotation", "Language:"] { + for label in [ + "Horizontal Scale", + "Vertical Scale", + "Baseline Shift", + "Tate-chu-yoko Up/Down", + "Tate-chu-yoko Left/Right", + "Character Rotation", + "Language:", + ] { assert!(shown.contains(label), "{label}: {shown}"); } } @@ -1569,6 +1583,85 @@ mod tests { assert!(!def.attrs.contains_key("v_scale")); } + #[test] + fn tate_chu_yoko_offsets_show_set_reset_and_keep_their_limits() { + let d = dialog_for(json!({"tcyUpDown": 2.0, "size": 12.0})); + assert_eq!((d.fields["tcyUpDown"].clone(), d.fields["tcyLeftRight"].clone()), (json!(2.0), json!(""))); + assert_eq!(edited_attrs(&d).unwrap(), attrs(json!({"tcyUpDown": 2.0, "size": 12.0}))); + // A value a style captured from scaled text stays while its field is untouched. + assert_eq!(edited_attrs(&dialog_for(json!({"tcyLeftRight": 3000.0}))).unwrap(), attrs(json!({"tcyLeftRight": 3000.0}))); + + let mut d = dialog_for(json!({"tcyUpDown": 2.0})); + d.fields.insert("tcyUpDown".into(), json!("")); + d.fields.insert("tcyLeftRight".into(), json!("-1296")); + assert_eq!(edited_attrs(&d).unwrap(), attrs(json!({"tcyLeftRight": -1296.0}))); + d.fields.insert("tcyLeftRight".into(), json!("0")); + assert_eq!(edited_attrs(&d).unwrap(), attrs(json!({"tcyLeftRight": 0.0}))); + for (key, value, message) in [ + ("tcyUpDown", "1296.1", "Tate-chu-yoko Up/Down must be a number from -1296 to 1296"), + ("tcyLeftRight", "-1296.1", "Tate-chu-yoko Left/Right must be a number from -1296 to 1296"), + ("tcyUpDown", "high", "Tate-chu-yoko Up/Down must be a number"), + ("tcyLeftRight", "NaN", "Tate-chu-yoko Left/Right must be a number"), + ] { + let mut d = dialog_for(json!({})); + d.fields.insert(key.into(), json!(value)); + assert_eq!(edited_attrs(&d).unwrap_err(), message, "{key} {value}"); + } + + let mut d = dialog_for(json!({"tcyUpDown": 2.0, "tcyLeftRight": -1.0, "size": 12.0})); + d.fields.insert("section".into(), json!("advanced")); + reset_section(&mut d); + assert_eq!(edited_attrs(&d).unwrap(), attrs(json!({"size": 12.0}))); + } + + #[test] + fn tate_chu_yoko_offsets_preview_cancel_and_commit_to_the_style_and_its_text() { + let mut app = app(); + app.run("charStyle.new", json!({"name": "Body", "attrs": {"size": 24.0, "tcyLeftRight": 0.0}})).unwrap(); + let id = app.run("text.create", json!({"x": 10, "y": 40, "text": "Text"})).unwrap()["id"].as_u64().unwrap(); + app.run("charStyle.apply", json!({"name": "Body", "id": id})).unwrap(); + app.run("text.setRangeStyle", json!({"id": id, "start": 2, "end": 4, "tcyLeftRight": 5.0})).unwrap(); + let before = app.session.doc().unwrap().doc.clone(); + let undo = app.session.doc().unwrap().history.undo.len(); + open(&mut app, "Body").unwrap(); + { + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("section".into(), json!("advanced")); + d.fields.insert("tcyUpDown".into(), json!("4")); + } + frame(&mut app); + assert_eq!(app.session.doc().unwrap().doc.char_styles[0].attrs["tcyUpDown"], json!(4.0)); + super::super::cancel(&mut app); + assert_eq!(app.session.doc().unwrap().doc, before); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo); + + open(&mut app, "Body").unwrap(); + { + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("tcyUpDown".into(), json!("4")); + d.fields.insert("tcyLeftRight".into(), json!("-3")); + } + super::super::confirm(&mut app).unwrap(); + let state = app.session.doc().unwrap(); + assert_eq!(state.history.undo.len(), undo + 1); + assert_eq!(state.doc.char_styles[0].attrs, attrs(json!({"size": 24.0, "tcyUpDown": 4.0, "tcyLeftRight": -3.0}))); + let vectorcraft_doc::NodeKind::Text(t) = &state.doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind else { panic!("text") }; + assert_eq!(t.runs.iter().map(|r| (r.style.tcy_up_down, r.style.tcy_left_right)).collect::>(), [(4.0, -3.0), (4.0, 5.0)]); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, before); + + app.run("ui.newCharStyle", json!({})).unwrap(); + { + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("name".into(), json!("Shifted")); + d.fields.insert("tcyLeftRight".into(), json!("6")); + } + super::super::confirm(&mut app).unwrap(); + let doc = &app.session.doc().unwrap().doc; + let def = doc.char_styles.iter().find(|def| def.name == "Shifted").unwrap(); + assert_eq!(def.attrs["tcyLeftRight"], json!(6.0)); + } + /// The negated tags of the twenty stylistic sets: an explicit empty Stylistic Sets choice. fn no_sets() -> Vec { vectorcraft_text::EXTRA_FEATURES[..20].iter().map(|s| format!("-{s}")).collect() diff --git a/crates/ui-egui/src/dialogs/command.rs b/crates/ui-egui/src/dialogs/command.rs index 3acd7e6c0..3ca205acd 100644 --- a/crates/ui-egui/src/dialogs/command.rs +++ b/crates/ui-egui/src/dialogs/command.rs @@ -64,11 +64,12 @@ pub(super) const SPEC: DialogSpec = DialogSpec { /// Does this dialog show a Preview checkbox? Grid Repeat Options does: the public page for the /// grid options lists one, and the radial and mirror pages do not. Type on a Path Options does /// too (#5360), as public tutorials of the dialog show, and Type Tools › Release Type on a Path -/// works the same way (#5532). (Cancel rolls a preview back whichever dialog started it.) +/// works the same way (#5532), and so does Image Tools › Remove Background. (Cancel rolls a +/// preview back whichever dialog started it.) fn previews(d: &Dialog) -> bool { match d.str("__command").as_str() { "object.repeat.options" => d.fields.contains_key("gridType"), - "type.pathOptions" | crate::addons::typetools::RELEASE => true, + "type.pathOptions" | crate::addons::typetools::RELEASE | crate::addons::image::REMOVE_BACKGROUND => true, _ => false, } } @@ -117,6 +118,7 @@ fn order(command: &str) -> &'static [&'static str] { "object.pathfinder.trap" => &["thickness", "heightWidth", "tintReduction", "trapsWithProcessColor", "reverseTraps"], "addon.color.printFixes" => &["inkLimit", "excludePureBlack", "removeWhiteOverprint", "overprintBlack", "textOnly", "fill", "stroke"], crate::addons::typetools::RELEASE => &["mode", "keepOriginal"], + crate::addons::image::REMOVE_BACKGROUND => &["background", "colors", "contiguous", "match", "tolerance", "softness"], _ => &[], } } @@ -136,6 +138,8 @@ fn choices(command: &str, key: &str) -> Option { ("graph.setType", "pieLegend") => Some(PIE_LEGEND), ("graph.setType", "piePosition") => Some(PIE_POSITION), ("graph.setType", "pieSort") => Some(PIE_SORT), + (crate::addons::image::REMOVE_BACKGROUND, "background") => Some(crate::addons::image::BACKGROUNDS), + (crate::addons::image::REMOVE_BACKGROUND, "match") => Some(crate::addons::image::MATCHES), _ => None, } } diff --git a/crates/ui-egui/src/dialogs/find_links.rs b/crates/ui-egui/src/dialogs/find_links.rs index f6b482f19..05f0e85b6 100644 --- a/crates/ui-egui/src/dialogs/find_links.rs +++ b/crates/ui-egui/src/dialogs/find_links.rs @@ -146,10 +146,12 @@ fn start_search(app: &mut VectorcraftApp, d: &mut Dialog, folder: &str) -> Resul Ok(()) } -/// The files a search found for `row` (`{path, ids}`), best first: the answer's `links` row with -/// the same path and ids. +/// The files a search found for `row` (`{path?, ids}`), best first: the answer's `links` row with +/// the same ids and, when both have one, the same path. A row has no `path` when its file is outside +/// an automation client's read roots. fn files_for<'a>(found: &'a [Value], row: &Value) -> &'a [Value] { - found.iter().find(|r| r["path"] == row["path"] && r["ids"] == row["ids"]).and_then(|r| r["files"].as_array()).map_or(&[], Vec::as_slice) + let same_path = |r: &Value| r["path"].is_null() || row["path"].is_null() || r["path"] == row["path"]; + found.iter().find(|r| r["ids"] == row["ids"] && same_path(r)).and_then(|r| r["files"].as_array()).map_or(&[], Vec::as_slice) } /// The file chosen at the start among `files`: the only one that is the same file, else the only diff --git a/crates/ui-egui/src/dialogs/find_links/tests.rs b/crates/ui-egui/src/dialogs/find_links/tests.rs index 36659c53b..4722b8b32 100644 --- a/crates/ui-egui/src/dialogs/find_links/tests.rs +++ b/crates/ui-egui/src/dialogs/find_links/tests.rs @@ -339,3 +339,12 @@ fn find_in_folder_asks_for_the_folder_off_the_ui_thread() { searched(&mut app); assert_eq!(pick(app.ui.dialog.as_ref().unwrap(), "here.png"), &json!(here)); } + +/// A row whose path `links.check` left out (its file is outside the read roots) still shows the +/// files the search found for its objects. +#[test] +fn a_row_without_its_path_shows_the_files_found_for_its_objects() { + let found = [json!({"name": "a.png", "path": "/elsewhere/a.png", "ids": [3], "files": [{"path": "/root/a.png"}]})]; + assert_eq!(files_for(&found, &json!({"name": "a.png", "ids": [3]})), [json!({"path": "/root/a.png"})]); + assert!(files_for(&found, &json!({"name": "a.png", "ids": [4]})).is_empty()); +} diff --git a/crates/ui-egui/src/dialogs/width_point/tests.rs b/crates/ui-egui/src/dialogs/width_point/tests.rs index f8b686cca..4c5b972f1 100644 --- a/crates/ui-egui/src/dialogs/width_point/tests.rs +++ b/crates/ui-egui/src/dialogs/width_point/tests.rs @@ -247,6 +247,8 @@ fn new_width_point_profile_and_compound_target() { assert!(d.fields.get("index").is_none_or(Value::is_null)); let actual_t = d.f64("t", -1.0); assert!((actual_t - 0.25).abs() < 1e-6, "compound={compound} pointer={quarter:?} actual_t={actual_t} want=0.25"); + // The two-point profile is linear: at a quarter its sides are 1.25 and 0.75 of + // 1..2 and 0.5..1.5, on a 10pt stroke (5pt half). assert!((d.f64("side1", 0.0) - 6.25).abs() < 1e-6); assert!((d.f64("side2", 0.0) - 3.75).abs() < 1e-6); assert!(!d.bool("linked")); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 24f16e179..bc67d8fd8 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -686,6 +686,15 @@ font-technology Variable Променливи Created: Създадено: Crop Изрязване Crop Image Изрязване на изображение + Image Tools Инструменти за изображения + Remove Background… Премахване на фона… + Remove Background Премахване на фона + Background Фон + Edge Color Цвят на ръба + Picked Colors Избрани цветове + Corner Color Цвят на ъглите + Contiguous Съседни + Each Channel Всеки канал Edit Path Редактиране на контура Mask Маска Crop Marks Знаци за изрязване @@ -3447,6 +3456,8 @@ digits Malayalam Малаялам Scale at Center Мащабиране от центъра Up/Down: Нагоре/надолу: Left/Right: Наляво/надясно: + Tate-chu-yoko Up/Down Тате-чу-йоко нагоре/надолу + Tate-chu-yoko Left/Right Тате-чу-йоко наляво/надясно Temperature Температура Temperature/Tint… Температура/Нюанс… Template Шаблон @@ -4365,6 +4376,7 @@ axis Both И двете @msg not a stored central record не е съхранен централен запис @msg not a valid plug-in module: {0} не е валиден модул за плъгин: {0} @msg not an EMF or WMF file не е EMF или WMF файл +@msg not an HP-GL file не е HP-GL файл @msg not found не е открито @msg not in pattern editing mode не е в режим на редактиране на мостра @msg finish editing the pattern first първо завършете редактирането на мострата diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 83290bdbd..6454cffcc 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -196,6 +196,15 @@ Expand… Proširi… Expand Appearance Proširi izgled Crop Image Izreži sliku + Image Tools Alati za slike + Remove Background… Ukloni pozadinu… + Remove Background Ukloni pozadinu + Background Pozadina + Edge Color Boja ruba + Picked Colors Odabrane boje + Corner Color Boja uglova + Contiguous Susjedni + Each Channel Svaki kanal Edit Path Uredi putanju Mask Maska Rasterize… Rasteriziraj… @@ -1564,6 +1573,8 @@ digits Malayalam Malajalam Scale at Center Skaliraj od centra Up/Down: Gore/dolje: Left/Right: Lijevo/desno: + Tate-chu-yoko Up/Down Tate-chu-yoko gore/dolje + Tate-chu-yoko Left/Right Tate-chu-yoko lijevo/desno Underline Podvučeno Vertical Scale % Okomita skala % Axis Osa @@ -4756,6 +4767,7 @@ imageTrace Outline Obris @msg not a stored central record nije sačuvani centralni zapis @msg not a valid plug-in module: {0} nije važeći modul dodatka: {0} @msg not an EMF or WMF file nije EMF ni WMF datoteka +@msg not an HP-GL file nije HP-GL datoteka @msg not an export presets file (format `{_1}`) nije datoteka predložaka izvoza (format `{_1}`) @msg not an export presets file: {e} nije datoteka predložaka izvoza: {e} @msg not found nije pronađeno diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 717c2b59d..1567e0e8b 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -985,6 +985,15 @@ antiAlias Sharp Nítid CRLF (Windows) CRLF (Windows) Crop Retalla Crop Image Retalla la imatge + Image Tools Eines d’imatge + Remove Background… Elimina el fons… + Remove Background Elimina el fons + Background Fons + Edge Color Color de la vora + Picked Colors Colors triats + Corner Color Color de les cantonades + Contiguous Contigu + Each Channel Cada canal Crop Marks Marques de retall Crop To Retalla fins a Cross Creu @@ -4441,6 +4450,8 @@ digits Malayalam Malaiàlam Scale at Center Escala des del centre Up/Down: Amunt/avall: Left/Right: Esquerra/dreta: + Tate-chu-yoko Up/Down Tate-chu-yoko amunt/avall + Tate-chu-yoko Left/Right Tate-chu-yoko esquerra/dreta Technical Drawing Dibuix tècnic Temperature Temperatura Temperature/Tint… Temperatura/To… @@ -5368,6 +5379,7 @@ digits Malayalam Malaiàlam @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller la pàgina del tauler {n} fa {size}, més que el límit de pàgina del PDF de 14.400 pt (200 in): s’escriu amb un UserUnit de {unit}, de manera que les aplicacions que ignoren UserUnit la mostren tantes vegades més petita @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in la pàgina del tauler {n} fa {size}, més que el límit de pàgina del PDF de 14.400 pt (200 in): les pàgines {label} s’escriuen sense UserUnit, en punts, i les aplicacions que respecten el límit retallen la pàgina a 200 in @msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 el PDF 1.5 no té UserUnit, que una pàgina de més de 14.400 pt (200 in) necessita per mantenir la mida: el fitxer s’escriu com a PDF 1.6 +@msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 el PDF 1.4 no té UserUnit, que una pàgina de més de 14.400 pt (200 in) necessita per quedar dins del límit de pàgina de 14.400 unitats del PDF/A-2: el fitxer s’escriu com a PDF 1.6 @msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units la pàgina del tauler {n} fa {size}, massa llarga i estreta per als costats de pàgina del PDF de 3 a 14.400 unitats: s'escriu amb una UserUnit de {unit}, que manté el costat curt en 3 unitats o més, i les aplicacions que respecten el límit retallen el costat llarg a 14.400 unitats @msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard els fitxers {label} admeten nombres reals fins a {max}, i la pàgina del tauler {n}, de {side} pt, en necessita de més grans: trieu PDF 1.6 o posterior sense estàndard PDF/X, o un tauler més petit @msg Invalid real scale Escala real no vàlida @@ -5604,6 +5616,7 @@ digits Malayalam Malaiàlam @msg not a VectorCraft file: {0} no és un fitxer de VectorCraft: {0} @msg not a VectorCraft shortcut set (missing `overrides`) no és un conjunt de dreceres de VectorCraft (falta `overrides`) @msg not an EMF or WMF file no és un fitxer EMF o WMF +@msg not an HP-GL file no és un fitxer HP-GL @msg not an RTF file: it doesn't start with {\\rtf no és un fitxer RTF: no comença amb {\\rtf @msg not found no s’ha trobat @msg not in pattern editing mode no esteu en mode d’edició de motius @@ -5754,6 +5767,14 @@ digits Malayalam Malaiàlam @msg exit isolation mode first surt primer del mode d’aïllament @msg select an envelope selecciona un embolcall @msg select an image selecciona una imatge +@msg `background` must be edges, corners or colors `background` ha de ser edges, corners o colors +@msg `match` must be distance or channels `match` ha de ser distance o channels +@msg `{key}` must be from 0 to 100 `{key}` ha d’estar entre 0 i 100 +@msg `colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue `colors` ha de llistar d’1 a 16 colors, cadascun en hexadecimal (#rrggbb o #rgb) o com a black, white, red, green o blue +@msg the linked image shows its preview: embed or relink it first la imatge enllaçada mostra la previsualització: incrusta-la o torna a enllaçar-la primer +@msg a CMYK image has no transparency to set una imatge CMYK no té transparència per establir +@msg every pixel matches the background: no image would be left cada píxel coincideix amb el fons: no quedaria cap imatge +@msg an image of {w} × {h} pixels is too large to change una imatge de {w} × {h} píxels és massa gran per canviar-la @msg select an Image Trace object selecciona un objecte d’Image Trace @msg select an Intertwine group selecciona un grup Intertwine @msg select an object in perspective selecciona un objecte en perspectiva diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 216579443..e9df0812f 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -202,6 +202,15 @@ Expand… Rozložit… Expand Appearance Rozložit vzhled Crop Image Oříznout obraz + Image Tools Nástroje obrazu + Remove Background… Odstranit pozadí… + Remove Background Odstranit pozadí + Background Pozadí + Edge Color Barva okraje + Picked Colors Vybrané barvy + Corner Color Barva rohů + Contiguous Souvislé + Each Channel Každý kanál Edit Path Upravit cestu Mask Maska Rasterize… Rastrovat… @@ -1588,6 +1597,8 @@ digits Malayalam Malajálamské Scale at Center Měřítko od středu Up/Down: Nahoru/dolů: Left/Right: Vlevo/vpravo: + Tate-chu-yoko Up/Down Tate-chu-yoko nahoru/dolů + Tate-chu-yoko Left/Right Tate-chu-yoko vlevo/vpravo Underline Podtržení Vertical Scale % Svislá změna velikosti % Axis Osa @@ -4691,6 +4702,7 @@ imageTrace Outline Obrys @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller stránka pracovní plochy {n} má {size}, což přesahuje limit stránky PDF 14 400 pt (200 in): zapisuje se s UserUnit {unit}, takže aplikace, které UserUnit ignorují, ji zobrazí tolikrát menší @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in stránka pracovní plochy {n} má {size}, což přesahuje limit stránky PDF 14 400 pt (200 in): stránky {label} se zapisují bez UserUnit, v bodech, a aplikace, které limit dodržují, stránku oříznou na 200 in @msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5 nemá UserUnit, který stránka delší než 14 400 pt (200 in) potřebuje, aby si zachovala velikost: soubor se zapíše jako PDF 1.6 +@msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 PDF 1.4 nemá UserUnit, který stránka delší než 14 400 pt (200 in) potřebuje, aby zůstala v limitu stránky PDF/A-2, 14 400 jednotek: soubor se zapíše jako PDF 1.6 @msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units stránka pracovní plochy {n} má rozměr {size} a je příliš dlouhá a úzká pro strany stránky PDF od 3 do 14 400 jednotek: zapisuje se s UserUnit {unit}, která drží kratší stranu na 3 jednotkách nebo více, a aplikace, které limit dodržují, ořežou delší stranu na 14 400 jednotek @msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard soubory {label} pojmou reálná čísla nejvýše {max} a stránka pracovní plochy {n}, dlouhá {side} pt, potřebuje větší: zvolte PDF 1.6 nebo novější bez standardu PDF/X, nebo menší pracovní plochu @msg invalid image data for `{0}` neplatná obrazová data pro `{0}` @@ -4838,6 +4850,7 @@ imageTrace Outline Obrys @msg not a stored central record nejde o uložený centrální záznam @msg not a valid plug-in module: {0} neplatný modul zásuvného modulu: {0} @msg not an EMF or WMF file nejde o soubor EMF ani WMF +@msg not an HP-GL file nejde o soubor HP-GL @msg not found nenalezeno @msg not in pattern editing mode není aktivní režim úprav vzorku @msg finish editing the pattern first nejprve dokončete úpravy vzorku @@ -4917,6 +4930,14 @@ imageTrace Outline Obrys @msg this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset tato vektorizace by vytvořila více než {max} kotevních bodů, víc, než dokument plynule zobrazí: zvyšte Šum, snižte Cesty nebo Barvy, nebo zvolte přednastavení s nižší věrností @msg select an envelope vyberte obálku @msg select an image vyberte obraz +@msg `background` must be edges, corners or colors `background` musí být edges, corners nebo colors +@msg `match` must be distance or channels `match` musí být distance nebo channels +@msg `{key}` must be from 0 to 100 `{key}` musí být od 0 do 100 +@msg `colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue `colors` musí obsahovat 1 až 16 barev, každou šestnáctkově (#rrggbb nebo #rgb) nebo jako black, white, red, green či blue +@msg the linked image shows its preview: embed or relink it first propojený obraz zobrazuje náhled: nejprve jej vložte nebo znovu propojte +@msg a CMYK image has no transparency to set obraz CMYK nemá průhlednost, kterou by bylo možné nastavit +@msg every pixel matches the background: no image would be left každý obrazový bod odpovídá pozadí: nezbyl by žádný obraz +@msg an image of {w} × {h} pixels is too large to change obraz o velikosti {w} × {h} obrazových bodů je příliš velký na změnu @msg select an object in perspective vyberte objekt v perspektivě @msg select artwork (or a colour group) to recolor vyberte kresbu (nebo skupinu barev) k přebarvení @msg Recolor completed; {locked} and {hidden} were left unchanged Přebarvení dokončeno; beze změny: {locked} a {hidden} diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 2339ca27e..e259ecba8 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -189,6 +189,15 @@ Expand… Udvid… Expand Appearance Udvid udseende Crop Image Beskær billede + Image Tools Billedværktøjer + Remove Background… Fjern baggrund… + Remove Background Fjern baggrund + Background Baggrund + Edge Color Kantfarve + Picked Colors Valgte farver + Corner Color Hjørnefarve + Contiguous Sammenhængende + Each Channel Hver kanal Edit Path Redigér kurve Mask Maskér Rasterize… Rasterisér… @@ -1499,6 +1508,8 @@ digits Malayalam Malayalam Scale at Center Skalér fra midten Up/Down: Op/ned: Left/Right: Venstre/højre: + Tate-chu-yoko Up/Down Tate-chu-yoko op/ned + Tate-chu-yoko Left/Right Tate-chu-yoko venstre/højre Underline Understreget Vertical Scale % Lodret skalering % Axis Akse @@ -4466,6 +4477,7 @@ imageTrace Outline Kontur @msg not a stored central record ikke en gemt central post @msg not a valid plug-in module: {0} ikke et gyldigt plug-in-modul: {0} @msg not an EMF or WMF file ikke en EMF- eller WMF-fil +@msg not an HP-GL file ikke en HP-GL-fil @msg not found ikke fundet @msg not in pattern editing mode ikke i mønsterredigeringstilstand @msg finish editing the pattern first afslut først redigeringen af mønsteret diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index e5ad28a0c..abfaaa13a 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -679,6 +679,15 @@ font-technology Variable Variabel Created: Erstellt: Crop Schnittmengenfläche Crop Image Bild beschneiden + Image Tools Bildwerkzeuge + Remove Background… Hintergrund entfernen… + Remove Background Hintergrund entfernen + Background Hintergrund + Edge Color Randfarbe + Picked Colors Ausgewählte Farben + Corner Color Eckfarbe + Contiguous Zusammenhängend + Each Channel Jeder Kanal Edit Path Pfad bearbeiten Mask Maske Crop Marks Schnittmarken @@ -3479,6 +3488,8 @@ effect Points Punkte Scale at Center Von der Mitte aus skalieren Up/Down: Oben/Unten: Left/Right: Links/Rechts: + Tate-chu-yoko Up/Down Tate-chu-yoko oben/unten + Tate-chu-yoko Left/Right Tate-chu-yoko links/rechts Temperature Temperatur Temperature/Tint… Temperatur/Farbstich… Template Vorlage @@ -4298,6 +4309,7 @@ theme Appearance Erscheinungsbild @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller die Seite der Zeichenfläche {n} ist {size} groß und damit länger als die PDF-Seitengrenze von 14.400 pt (200 in): sie wird mit einer UserUnit von {unit} geschrieben, daher zeigen Apps, die UserUnit ignorieren, sie entsprechend oft kleiner an @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in die Seite der Zeichenfläche {n} ist {size} groß und damit länger als die PDF-Seitengrenze von 14.400 pt (200 in): {label}-Seiten werden ohne UserUnit in Punkt geschrieben, und Apps, die die Grenze einhalten, schneiden die Seite auf 200 in ab @msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5 hat keine UserUnit, die eine Seite länger als 14.400 pt (200 in) braucht, um ihre Größe zu behalten: die Datei wird als PDF 1.6 geschrieben +@msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 PDF 1.4 hat keine UserUnit, die eine Seite länger als 14.400 pt (200 in) braucht, um innerhalb der PDF/A-2-Seitengrenze von 14.400 Einheiten zu bleiben: die Datei wird als PDF 1.6 geschrieben @msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units die Seite der Zeichenfläche {n} ist {size} groß und damit zu lang und schmal für PDF-Seitenlängen von 3 bis 14.400 Einheiten: sie wird mit einer UserUnit von {unit} geschrieben, die die kurze Seite bei mindestens 3 Einheiten hält, und Apps, die die Grenze einhalten, schneiden die lange Seite auf 14.400 Einheiten ab @msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard {label}-Dateien fassen reelle Zahlen bis {max}, und die Seite der Zeichenfläche {n} mit {side} pt Länge braucht größere: wählen Sie PDF 1.6 oder höher ohne PDF/X-Standard oder eine kleinere Zeichenfläche @msg invalid image data for `{0}` ungültige Bilddaten für `{0}` @@ -4447,6 +4459,7 @@ theme Appearance Erscheinungsbild @msg not a stored central record kein gespeicherter zentraler Eintrag @msg not a valid plug-in module: {0} kein gültiges Plug-in-Modul: {0} @msg not an EMF or WMF file keine EMF- oder WMF-Datei +@msg not an HP-GL file keine HP-GL-Datei @msg not found nicht gefunden @msg not in pattern editing mode nicht im Musterbearbeitungsmodus @msg finish editing the pattern first beenden Sie zuerst die Bearbeitung des Musters @@ -4526,6 +4539,14 @@ theme Appearance Erscheinungsbild @msg this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset diese Nachzeichnung würde mehr als {max} Ankerpunkte erzeugen, mehr als das Dokument flüssig darstellen kann: Erhöhen Sie „Rauschen“, verringern Sie „Pfade“ oder „Farben“ oder wählen Sie eine Vorgabe mit geringerer Genauigkeit @msg select an envelope wählen Sie eine Hülle aus @msg select an image wählen Sie ein Bild aus +@msg `background` must be edges, corners or colors `background` muss edges, corners oder colors sein +@msg `match` must be distance or channels `match` muss distance oder channels sein +@msg `{key}` must be from 0 to 100 `{key}` muss von 0 bis 100 sein +@msg `colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue `colors` muss 1 bis 16 Farben enthalten, jede hexadezimal (#rrggbb oder #rgb) oder als black, white, red, green oder blue +@msg the linked image shows its preview: embed or relink it first das verknüpfte Bild zeigt seine Vorschau: Betten Sie es zuerst ein oder verknüpfen Sie es neu +@msg a CMYK image has no transparency to set ein CMYK-Bild hat keine Transparenz, die festgelegt werden kann +@msg every pixel matches the background: no image would be left jedes Pixel entspricht dem Hintergrund: Es würde kein Bild übrig bleiben +@msg an image of {w} × {h} pixels is too large to change ein Bild mit {w} × {h} Pixeln ist zu groß, um geändert zu werden @msg select an object in perspective wählen Sie ein Objekt in der Perspektive aus @msg select artwork (or a colour group) to recolor wählen Sie eine Grafik (oder eine Farbgruppe) zum Neufärben aus @msg Recolor completed; {locked} and {hidden} were left unchanged Neufärben abgeschlossen; unverändert: {locked} und {hidden} diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index c87ecb571..4611904ff 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -660,6 +660,15 @@ font-technology Variable Μεταβλητές Created: Δημιουργήθηκε: Crop Περικοπή Crop Image Περικοπή εικόνας + Image Tools Εργαλεία εικόνας + Remove Background… Αφαίρεση φόντου… + Remove Background Αφαίρεση φόντου + Background Φόντο + Edge Color Χρώμα άκρης + Picked Colors Επιλεγμένα χρώματα + Corner Color Χρώμα γωνιών + Contiguous Συνεχόμενα + Each Channel Κάθε κανάλι Edit Path Επεξεργασία διαδρομής Mask Μάσκα Crop Marks Σημάδια ξακρίσματος @@ -3918,6 +3927,8 @@ digits Malayalam Μαλαγιάλαμ Scale at Center Κλιμάκωση από το κέντρο Up/Down: Πάνω/Κάτω: Left/Right: Αριστερά/Δεξιά: + Tate-chu-yoko Up/Down Tate-chu-yoko πάνω/κάτω + Tate-chu-yoko Left/Right Tate-chu-yoko αριστερά/δεξιά Temperature/Tint… Θερμοκρασία/Απόχρωση… Templates Folder Φάκελος προτύπων Text Displayed in Cell: Κείμενο που εμφανίζεται στο κελί: @@ -4337,6 +4348,7 @@ digits Malayalam Μαλαγιάλαμ @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller η σελίδα του πίνακα σχεδίασης {n} είναι {size}, μεγαλύτερη από το όριο σελίδας PDF των 14.400 pt (200 in): γράφεται με UserUnit {unit}, οπότε οι εφαρμογές που αγνοούν το UserUnit την εμφανίζουν τόσες φορές μικρότερη @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in η σελίδα του πίνακα σχεδίασης {n} είναι {size}, μεγαλύτερη από το όριο σελίδας PDF των 14.400 pt (200 in): οι σελίδες {label} γράφονται χωρίς UserUnit, σε στιγμές, και οι εφαρμογές που τηρούν το όριο κόβουν τη σελίδα στις 200 in @msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 το PDF 1.5 δεν έχει UserUnit, που χρειάζεται μια σελίδα μεγαλύτερη από 14.400 pt (200 in) για να κρατήσει το μέγεθός της: το αρχείο γράφεται ως PDF 1.6 +@msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 το PDF 1.4 δεν έχει UserUnit, που χρειάζεται μια σελίδα μεγαλύτερη από 14.400 pt (200 in) για να μείνει εντός του ορίου σελίδας του PDF/A-2, 14.400 μονάδων: το αρχείο γράφεται ως PDF 1.6 @msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units η σελίδα του πίνακα σχεδίασης {n} είναι {size}, πολύ μακριά και στενή για τις πλευρές σελίδας PDF από 3 έως 14.400 μονάδες: γράφεται με UserUnit {unit}, που κρατά τη μικρή πλευρά στις 3 μονάδες ή περισσότερο, και οι εφαρμογές που τηρούν το όριο κόβουν τη μεγάλη πλευρά στις 14.400 μονάδες @msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard τα αρχεία {label} δέχονται πραγματικούς αριθμούς έως {max}, και η σελίδα του πίνακα σχεδίασης {n}, μήκους {side} pt, χρειάζεται μεγαλύτερους: επιλέξτε PDF 1.6 ή νεότερο χωρίς πρότυπο PDF/X, ή μικρότερο πίνακα σχεδίασης @msg invalid image data for `{0}` μη έγκυρα δεδομένα εικόνας για `{0}` @@ -4485,6 +4497,7 @@ digits Malayalam Μαλαγιάλαμ @msg not a stored central record δεν είναι αποθηκευμένη κεντρική εγγραφή @msg not a valid plug-in module: {0} δεν είναι έγκυρη μονάδα προσθέτου: {0} @msg not an EMF or WMF file δεν είναι αρχείο EMF ή WMF +@msg not an HP-GL file δεν είναι αρχείο HP-GL @msg not found δεν βρέθηκε @msg not in pattern editing mode δεν βρίσκεστε σε λειτουργία επεξεργασίας μοτίβου @msg finish editing the pattern first ολοκληρώστε πρώτα την επεξεργασία του μοτίβου @@ -4558,6 +4571,14 @@ digits Malayalam Μαλαγιάλαμ @msg this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset αυτή η διανυσματοποίηση θα δημιουργούσε πάνω από {max} σημεία αγκύρωσης, περισσότερα από όσα μπορεί να εμφανίσει ομαλά το έγγραφο: αυξήστε τον Θόρυβο, μειώστε τις Διαδρομές ή τα Χρώματα ή επιλέξτε προεπιλογή χαμηλότερης πιστότητας @msg select an envelope επιλέξτε περίβλημα @msg select an image επιλέξτε εικόνα +@msg `background` must be edges, corners or colors το `background` πρέπει να είναι edges, corners ή colors +@msg `match` must be distance or channels το `match` πρέπει να είναι distance ή channels +@msg `{key}` must be from 0 to 100 το `{key}` πρέπει να είναι από 0 έως 100 +@msg `colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue το `colors` πρέπει να περιέχει 1 έως 16 χρώματα, το καθένα σε δεκαεξαδική μορφή (#rrggbb ή #rgb) ή ως black, white, red, green ή blue +@msg the linked image shows its preview: embed or relink it first η συνδεδεμένη εικόνα εμφανίζει την προεπισκόπησή της: ενσωματώστε ή επανασυνδέστε την πρώτα +@msg a CMYK image has no transparency to set μια εικόνα CMYK δεν έχει διαφάνεια για να οριστεί +@msg every pixel matches the background: no image would be left κάθε εικονοστοιχείο ταιριάζει με το φόντο: δεν θα έμενε καμία εικόνα +@msg an image of {w} × {h} pixels is too large to change η εικόνα {w} × {h} εικονοστοιχείων είναι υπερβολικά μεγάλη για να αλλάξει @msg select an object in perspective επιλέξτε αντικείμενο σε προοπτική @msg select artwork (or a colour group) to recolor επιλέξτε σχέδιο (ή ομάδα χρωμάτων) για επαναχρωματισμό @msg Recolor completed; {locked} and {hidden} were left unchanged Ο επαναχρωματισμός ολοκληρώθηκε· χωρίς αλλαγή: {locked} και {hidden} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index db8da4cc3..b91d89b8c 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -700,6 +700,15 @@ font-technology Variable Variables Created: Creado: Crop Recortar Crop Image Recortar imagen + Image Tools Herramientas de imagen + Remove Background… Quitar fondo… + Remove Background Quitar fondo + Background Fondo + Edge Color Color del borde + Picked Colors Colores elegidos + Corner Color Color de las esquinas + Contiguous Contiguo + Each Channel Cada canal Edit Path Editar trazado Mask Máscara Crop Marks Marcas de corte @@ -3534,6 +3543,8 @@ digits Malayalam Malayálam Scale at Center Escalar desde el centro Up/Down: Arriba/abajo: Left/Right: Izquierda/derecha: + Tate-chu-yoko Up/Down Tate-chu-yoko arriba/abajo + Tate-chu-yoko Left/Right Tate-chu-yoko izquierda/derecha Temperature Temperatura Temperature/Tint… Temperatura/tinte… Template Plantilla @@ -4382,6 +4393,7 @@ axis Both Ambos @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller la página de la mesa de trabajo {n} mide {size}, más que el límite de página de PDF de 14.400 pt (200 in): se escribe con un UserUnit de {unit}, así que las aplicaciones que ignoran UserUnit la muestran ese número de veces más pequeña @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in la página de la mesa de trabajo {n} mide {size}, más que el límite de página de PDF de 14.400 pt (200 in): las páginas {label} se escriben sin UserUnit, en puntos, y las aplicaciones que respetan el límite recortan la página a 200 in @msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5 no tiene UserUnit, que una página de más de 14.400 pt (200 in) necesita para conservar su tamaño: el archivo se escribe como PDF 1.6 +@msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 PDF 1.4 no tiene UserUnit, que una página de más de 14.400 pt (200 in) necesita para no superar el límite de página de PDF/A-2, de 14.400 unidades: el archivo se escribe como PDF 1.6 @msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units la página de la mesa de trabajo {n} mide {size}, demasiado larga y estrecha para los lados de página de PDF de 3 a 14.400 unidades: se escribe con una UserUnit de {unit}, que mantiene el lado corto en 3 unidades o más, y las aplicaciones que respetan el límite recortan el lado largo a 14.400 unidades @msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard los archivos {label} admiten números reales de hasta {max}, y la página de la mesa de trabajo {n}, de {side} pt, necesita otros mayores: elija PDF 1.6 o posterior sin estándar PDF/X, o una mesa de trabajo más pequeña @msg invalid image data for `{0}` datos de imagen no válidos para `{0}` @@ -4530,6 +4542,7 @@ axis Both Ambos @msg not a stored central record no es un registro central almacenado @msg not a valid plug-in module: {0} no es un módulo de plugin válido: {0} @msg not an EMF or WMF file no es un archivo EMF ni WMF +@msg not an HP-GL file no es un archivo HP-GL @msg not found no se encontró @msg not in pattern editing mode no está en el modo de edición de motivos @msg finish editing the pattern first termina primero la edición del motivo @@ -4609,6 +4622,14 @@ axis Both Ambos @msg this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset este calco crearía más de {max} puntos de ancla, más de los que el documento puede mostrar con fluidez: sube Ruido, baja Trazados o Colores, o elige un preajuste de menor fidelidad @msg select an envelope selecciona una envolvente @msg select an image selecciona una imagen +@msg `background` must be edges, corners or colors `background` debe ser edges, corners o colors +@msg `match` must be distance or channels `match` debe ser distance o channels +@msg `{key}` must be from 0 to 100 `{key}` debe estar entre 0 y 100 +@msg `colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue `colors` debe contener de 1 a 16 colores, cada uno en hexadecimal (#rrggbb o #rgb) o como black, white, red, green o blue +@msg the linked image shows its preview: embed or relink it first la imagen vinculada muestra su vista previa: incrústala o vuelve a vincularla primero +@msg a CMYK image has no transparency to set una imagen CMYK no tiene transparencia que establecer +@msg every pixel matches the background: no image would be left todos los píxeles coinciden con el fondo: no quedaría ninguna imagen +@msg an image of {w} × {h} pixels is too large to change una imagen de {w} × {h} píxeles es demasiado grande para cambiarla @msg select an object in perspective selecciona un objeto en perspectiva @msg select artwork (or a colour group) to recolor selecciona la ilustración (o un grupo de colores) que quieres volver a colorear @msg Recolor completed; {locked} and {hidden} were left unchanged Recoloreado completado; sin cambios: {locked} y {hidden} diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index c3d7fcfb9..e29645989 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -209,6 +209,15 @@ Expand… Laajenna… Expand Appearance Laajenna ulkoasu Crop Image Rajaa kuva + Image Tools Kuvatyökalut + Remove Background… Poista tausta… + Remove Background Poista tausta + Background Tausta + Edge Color Reunan väri + Picked Colors Valitut värit + Corner Color Kulmien väri + Contiguous Yhtenäinen + Each Channel Jokainen kanava Edit Path Muokkaa polkua Mask Maski Rasterize… Rasteroi… @@ -1561,6 +1570,8 @@ digits Malayalam Malajalam Scale at Center Skaalaa keskeltä Up/Down: Ylös/alas: Left/Right: Vasen/oikea: + Tate-chu-yoko Up/Down Tate-chu-yoko ylös/alas + Tate-chu-yoko Left/Right Tate-chu-yoko vasen/oikea Underline Alleviivaus Vertical Scale % Pystyskaalaus % Axis Akseli @@ -4599,6 +4610,7 @@ imageTrace Outline Ääriviiva @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller piirtoalustan {n} sivu on {size}, pidempi kuin PDF:n 14 400 pt:n (200 in) sivuraja: se kirjoitetaan UserUnit-arvolla {unit}, joten UserUnitin ohittavat sovellukset näyttävät sen niin monta kertaa pienempänä @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in piirtoalustan {n} sivu on {size}, pidempi kuin PDF:n 14 400 pt:n (200 in) sivuraja: {label}-sivut kirjoitetaan ilman UserUnitia, pisteinä, ja rajaa noudattavat sovellukset rajaavat sivun 200 in:iin @msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5:ssä ei ole UserUnitia, jota yli 14 400 pt:n (200 in) sivu tarvitsee säilyttääkseen kokonsa: tiedosto kirjoitetaan PDF 1.6:na +@msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 PDF 1.4:ssä ei ole UserUnitia, jota yli 14 400 pt:n (200 in) sivu tarvitsee pysyäkseen PDF/A-2:n 14 400 yksikön sivurajan sisällä: tiedosto kirjoitetaan PDF 1.6:na @msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units piirtoalustan {n} sivu on {size}, liian pitkä ja kapea PDF:n sivuille, jotka ovat 3:sta 14 400 yksikköön: se kirjoitetaan UserUnit-arvolla {unit}, joka pitää lyhyen sivun vähintään 3 yksikössä, ja rajaa noudattavat sovellukset leikkaavat pitkän sivun 14 400 yksikköön @msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard {label}-tiedostot sallivat reaaliluvut enintään {max}, ja piirtoalustan {n} sivu, {side} pt pitkä, tarvitsee suurempia: valitse PDF 1.6 tai uudempi ilman PDF/X-standardia tai pienempi piirtoalusta @msg invalid image data for `{0}` virheellinen kuvadata kohteelle `{0}` @@ -4746,6 +4758,7 @@ imageTrace Outline Ääriviiva @msg not a stored central record ei tallennettu keskushakemistotietue @msg not a valid plug-in module: {0} ei kelvollinen laajennusmoduuli: {0} @msg not an EMF or WMF file ei EMF- tai WMF-tiedosto +@msg not an HP-GL file ei HP-GL-tiedosto @msg not found ei löytynyt @msg not in pattern editing mode ei kuvion muokkaustilassa @msg finish editing the pattern first lopeta ensin kuvion muokkaus @@ -4819,6 +4832,14 @@ imageTrace Outline Ääriviiva @msg this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset tämä jäljitys tuottaisi yli {max} ankkuripistettä, enemmän kuin asiakirja näyttää sujuvasti: nosta kohinaa, vähennä polkuja tai värejä tai valitse epätarkempi esiasetus @msg select an envelope valitse vääristyskuori @msg select an image valitse kuva +@msg `background` must be edges, corners or colors `background`-arvon on oltava edges, corners tai colors +@msg `match` must be distance or channels `match`-arvon on oltava distance tai channels +@msg `{key}` must be from 0 to 100 `{key}`-arvon on oltava väliltä 0–100 +@msg `colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue `colors`-arvossa on oltava 1–16 väriä, kukin heksadesimaalina (#rrggbb tai #rgb) tai muodossa black, white, red, green tai blue +@msg the linked image shows its preview: embed or relink it first linkitetty kuva näyttää esikatselunsa: upota tai linkitä se ensin uudelleen +@msg a CMYK image has no transparency to set CMYK-kuvalla ei ole asetettavaa läpinäkyvyyttä +@msg every pixel matches the background: no image would be left jokainen pikseli vastaa taustaa: kuvaa ei jäisi jäljelle +@msg an image of {w} × {h} pixels is too large to change kuva, jonka koko on {w} × {h} pikseliä, on liian suuri muutettavaksi @msg select an object in perspective valitse perspektiivissä oleva objekti @msg select artwork (or a colour group) to recolor valitse uudelleenväritettävä kuvitus (tai väriryhmä) @msg Recolor completed; {locked} and {hidden} were left unchanged Uudelleenväritys valmis; muuttamatta jäi: {locked} ja {hidden} diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index e557d273c..ff206b931 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -702,6 +702,15 @@ font-technology Variable Variables Created: Création : Crop Recadrer Crop Image Recadrer l’image + Image Tools Outils d’image + Remove Background… Supprimer l’arrière-plan… + Remove Background Supprimer l’arrière-plan + Background Arrière-plan + Edge Color Couleur du bord + Picked Colors Couleurs choisies + Corner Color Couleur des angles + Contiguous Contigu + Each Channel Chaque canal Edit Path Modifier le tracé Mask Masque Crop Marks Traits de coupe @@ -3526,6 +3535,8 @@ digits Malayalam Malayalam Scale at Center Mettre à l’échelle depuis le centre Up/Down: Haut/bas : Left/Right: Gauche/droite : + Tate-chu-yoko Up/Down Tate-chu-yoko haut/bas + Tate-chu-yoko Left/Right Tate-chu-yoko gauche/droite Temperature Température Temperature/Tint… Température/teinte… Template Modèle @@ -4370,6 +4381,7 @@ axis Both Les deux @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller la page du plan de travail {n} mesure {size}, plus que la limite de page PDF de 14 400 pt (200 in) : elle est écrite avec une UserUnit de {unit}, donc les applications qui ignorent UserUnit l’affichent autant de fois plus petite @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in la page du plan de travail {n} mesure {size}, plus que la limite de page PDF de 14 400 pt (200 in) : les pages {label} sont écrites sans UserUnit, en points, et les applications qui respectent la limite coupent la page à 200 in @msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5 n’a pas d’UserUnit, dont une page de plus de 14 400 pt (200 in) a besoin pour garder sa taille : le fichier est écrit en PDF 1.6 +@msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 PDF 1.4 n’a pas d’UserUnit, dont une page de plus de 14 400 pt (200 in) a besoin pour rester dans la limite de page de PDF/A-2, 14 400 unités : le fichier est écrit en PDF 1.6 @msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units la page du plan de travail {n} mesure {size}, trop longue et étroite pour les côtés de page PDF de 3 à 14 400 unités : elle est écrite avec une UserUnit de {unit}, qui garde le petit côté à 3 unités ou plus, et les apps qui respectent la limite coupent le grand côté à 14 400 unités @msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard les fichiers {label} acceptent des nombres réels jusqu’à {max}, et la page du plan de travail {n}, longue de {side} pt, en demande de plus grands : choisissez PDF 1.6 ou ultérieur sans norme PDF/X, ou un plan de travail plus petit @msg invalid PDF setting: {0} réglage PDF non valide : {0} @@ -4519,6 +4531,7 @@ axis Both Les deux @msg not a stored central record ce n’est pas un enregistrement central stocké @msg not a valid plug-in module: {0} ce n’est pas un module de plug-in valide : {0} @msg not an EMF or WMF file ce n’est pas un fichier EMF ou WMF +@msg not an HP-GL file ce n’est pas un fichier HP-GL @msg not found introuvable @msg not in pattern editing mode pas en mode d’édition de motif @msg finish editing the pattern first terminez d’abord la modification du motif @@ -4598,6 +4611,14 @@ axis Both Les deux @msg this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset cette vectorisation créerait plus de {max} points d’ancrage, plus que le document ne peut afficher de façon fluide : augmentez Bruit, réduisez Tracés ou Couleurs, ou choisissez un paramètre prédéfini moins fidèle @msg select an envelope sélectionnez une enveloppe @msg select an image sélectionnez une image +@msg `background` must be edges, corners or colors `background` doit valoir edges, corners ou colors +@msg `match` must be distance or channels `match` doit valoir distance ou channels +@msg `{key}` must be from 0 to 100 `{key}` doit être compris entre 0 et 100 +@msg `colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue `colors` doit contenir de 1 à 16 couleurs, chacune en hexadécimal (#rrggbb ou #rgb) ou sous la forme black, white, red, green ou blue +@msg the linked image shows its preview: embed or relink it first l’image liée affiche son aperçu : incorporez-la ou rétablissez d’abord le lien +@msg a CMYK image has no transparency to set une image CMJN n’a pas de transparence à définir +@msg every pixel matches the background: no image would be left chaque pixel correspond à l’arrière-plan : il ne resterait aucune image +@msg an image of {w} × {h} pixels is too large to change une image de {w} × {h} pixels est trop grande pour être modifiée @msg select an object in perspective sélectionnez un objet en perspective @msg select artwork (or a colour group) to recolor sélectionnez l’illustration (ou un groupe de couleurs) dont redéfinir les couleurs @msg Recolor completed; {locked} and {hidden} were left unchanged Recoloration terminée ; sans changement : {locked} et {hidden} diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 129dfeea3..6698f10a4 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -1099,6 +1099,15 @@ antiAlias Sharp शार्प Croatian क्रोएशियाई Crop क्रॉप करें Crop Image फसल की इमेज + Image Tools इमेज टूल + Remove Background… पृष्ठभूमि हटाएँ… + Remove Background पृष्ठभूमि हटाएँ + Background पृष्ठभूमि + Edge Color किनारे का रंग + Picked Colors चुने गए रंग + Corner Color कोनों का रंग + Contiguous सन्निहित + Each Channel प्रत्येक चैनल Crop Marks क्रॉप मार्क Crop To करने के लिए फसल Cross क्रॉस @@ -2458,6 +2467,8 @@ antiAlias Sharp शार्प Left-to-Right Paragraph Direction बाएँ-से-दाएँ पैराग्राफ़ दिशा Left: बाएँ: Left/Right: बाएं / दाएं: + Tate-chu-yoko Up/Down ताते-चू-योको ऊपर/नीचे + Tate-chu-yoko Left/Right ताते-चू-योको बाएं/दाएं Legacy .ai Options पुराने .ai फ़ॉर्मैट के ऑप्शन्स Legacy (version 7) पुराना फ़ॉर्मैट (संस्करण 7) Legacy Macintosh RGB (Gamma 1.8) पुराना Macintosh RGB (गामा 1.8) @@ -6588,6 +6599,7 @@ antiAlias Sharp शार्प @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller आर्टबोर्ड {n} का पेज {size} है, जो PDF की 14,400 pt (200 in) की पेज सीमा से लंबा है: इसे {unit} के UserUnit के साथ लिखा जाता है, इसलिए UserUnit को अनदेखा करने वाले ऐप इसे उतने गुना छोटा दिखाते हैं @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in आर्टबोर्ड {n} का पेज {size} है, जो PDF की 14,400 pt (200 in) की पेज सीमा से लंबा है: {label} पेज बिना UserUnit के, पॉइंट में लिखे जाते हैं, और सीमा मानने वाले ऐप पेज को 200 in पर काट देते हैं @msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5 में UserUnit नहीं है, जिसकी 14,400 pt (200 in) से लंबे पेज को अपना आकार बनाए रखने के लिए ज़रूरत होती है: फ़ाइल PDF 1.6 के रूप में लिखी जाती है +@msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 PDF 1.4 में UserUnit नहीं है, जिसकी 14,400 pt (200 in) से लंबे पेज को PDF/A-2 की 14,400 इकाइयों की पेज सीमा के भीतर रहने के लिए ज़रूरत होती है: फ़ाइल PDF 1.6 के रूप में लिखी जाती है @msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units आर्टबोर्ड {n} का पेज {size} है, जो PDF की 3 से 14,400 इकाई की पेज भुजाओं के लिए बहुत लंबा और संकरा है: इसे {unit} की UserUnit के साथ लिखा जाता है, जो छोटी भुजा को 3 इकाई या अधिक पर रखती है, और सीमा का पालन करने वाले ऐप लंबी भुजा को 14,400 इकाई पर काट देते हैं @msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard {label} फ़ाइलें {max} तक की वास्तविक संख्याएँ रखती हैं, और आर्टबोर्ड {n} का {side} pt लंबा पेज इससे बड़ी संख्याएँ माँगता है: बिना PDF/X मानक के PDF 1.6 या बाद का चुनें, या छोटा आर्टबोर्ड @msg invalid point अमानवीय बिंदु @@ -7080,6 +7092,7 @@ antiAlias Sharp शार्प @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out “{name}” लोड किया गया: {n} चरण छोड़ा गया|“{name}” लोड किया गया: {n} चरण छोड़े गए @msg not an action set file एक एक्शन सेट फ़ाइल नहीं @msg not an EMF or WMF file नहीं एक EMF या WMF फ़ाइल +@msg not an HP-GL file HP-GL फ़ाइल नहीं है @msg not an export presets file (format `{_1}`) नहीं निर्यात presets फ़ाइल (format `{_1}`) @msg not an export presets file: {e} एक निर्यात प्रीसेट फ़ाइल नहीं: {e} @msg not an image VectorCraft reads (see document.formats) यह ऐसी इमेज नहीं है जिसे VectorCraft पढ़ सके (document.formats देखें) @@ -7371,6 +7384,14 @@ antiAlias Sharp शार्प @msg select a text object एक टेक्स्ट ऑब्जेक्ट चुनें @msg select an envelope एक लिफाफे सिलेक्ट करें @msg select an image एक इमेज चुनें +@msg `background` must be edges, corners or colors `background` edges, corners या colors होना चाहिए +@msg `match` must be distance or channels `match` distance या channels होना चाहिए +@msg `{key}` must be from 0 to 100 `{key}` 0 से 100 तक होना चाहिए +@msg `colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue `colors` में 1 से 16 रंग होने चाहिए, हर एक हेक्स (#rrggbb या #rgb) में या black, white, red, green या blue के रूप में +@msg the linked image shows its preview: embed or relink it first लिंक्ड इमेज अपना पूर्वावलोकन दिखा रही है: पहले इसे एम्बेड करें या फिर से लिंक करें +@msg a CMYK image has no transparency to set CMYK इमेज में सेट करने के लिए कोई पारदर्शिता नहीं है +@msg every pixel matches the background: no image would be left हर पिक्सेल पृष्ठभूमि से मेल खाता है: कोई इमेज नहीं बचेगी +@msg an image of {w} × {h} pixels is too large to change {w} × {h} पिक्सेल की इमेज बदलने के लिए बहुत बड़ी है @msg select an image (or an Image Trace group) एक इमेज सिलेक्ट करें (या एक इमेज ट्रेस समूह) @msg select an Image Trace object एक इमेज ट्रेस ऑब्जेक्ट चुनें @msg select an image, or pass id एक इमेज सिलेक्ट करें, या आईडी पास करें diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 78c096b11..a8d02ca4c 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -901,6 +901,15 @@ font-technology Variable Változó CRLF (Windows) CRLF (Windows) Crop Körülvágás Crop Image Kép körülvágása + Image Tools Képeszközök + Remove Background… Háttér eltávolítása… + Remove Background Háttér eltávolítása + Background Háttér + Edge Color Szélszín + Picked Colors Kiválasztott színek + Corner Color Sarokszín + Contiguous Összefüggő + Each Channel Minden csatorna Edit Path Útvonal szerkesztése Crop Marks Vágójelek Crop To Körülvágás @@ -4195,6 +4204,8 @@ mark-style Japanese Japán Scale at Center Méretezés középről Up/Down: Fel/le: Left/Right: Balra/jobbra: + Tate-chu-yoko Up/Down Tate-chu-yoko fel/le + Tate-chu-yoko Left/Right Tate-chu-yoko balra/jobbra Technical Drawing Műszaki rajz Temperature Színhőmérséklet Temperature/Tint… Színhőmérséklet/tónus… @@ -5021,6 +5032,7 @@ imageTrace Outline Körvonal @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller a(z) {n}. rajztábla oldala {size}, hosszabb a PDF 14 400 pt-os (200 in) oldalkorlátjánál: {unit} UserUnit értékkel íródik, így a UserUnit-ot figyelmen kívül hagyó alkalmazások ennyiszer kisebbnek mutatják @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in a(z) {n}. rajztábla oldala {size}, hosszabb a PDF 14 400 pt-os (200 in) oldalkorlátjánál: a(z) {label} oldalak UserUnit nélkül, pontokban íródnak, és a korlátot betartó alkalmazások 200 in-re vágják az oldalt @msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 a PDF 1.5-ben nincs UserUnit, amelyre egy 14 400 pt-nál (200 in) hosszabb oldalnak szüksége van a mérete megtartásához: a fájl PDF 1.6-ként íródik +@msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 a PDF 1.4-ben nincs UserUnit, amelyre egy 14 400 pt-nál (200 in) hosszabb oldalnak szüksége van, hogy a PDF/A-2 14 400 egységes oldalkorlátján belül maradjon: a fájl PDF 1.6-ként íródik @msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units a(z) {n}. rajztábla oldala {size}, túl hosszú és keskeny a PDF 3 és 14 400 egység közötti oldalaihoz: {unit} UserUnit értékkel íródik, amely a rövid oldalt legalább 3 egységen tartja, és a korlátot betartó alkalmazások a hosszú oldalt 14 400 egységre vágják @msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard a(z) {label} fájlok legfeljebb {max} értékű valós számokat tárolnak, a(z) {n}. rajztábla {side} pt hosszú oldalához pedig nagyobbak kellenek: válassza a PDF 1.6-os vagy újabb verziót PDF/X szabvány nélkül, vagy egy kisebb rajztáblát @msg invalid restored panel group érvénytelen visszaállított panelcsoport @@ -5215,6 +5227,7 @@ imageTrace Outline Körvonal @msg not a VectorCraft shortcut set (missing `overrides`) nem VectorCraft-billentyűparancskészlet (hiányzik: `overrides`) @msg not a blend nem összemosás @msg not an EMF or WMF file nem EMF- vagy WMF-fájl +@msg not an HP-GL file nem HP-GL-fájl @msg not found nem található @msg not in pattern editing mode nem mintázatszerkesztő módban vagy @msg finish editing the pattern first előbb fejezd be a mintázat szerkesztését @@ -5360,6 +5373,14 @@ imageTrace Outline Körvonal @msg a symbol can't hold an instance of itself egy szimbólum nem tartalmazhatja a saját példányát @msg select an envelope jelölj ki egy burkolót @msg select an image jelölj ki egy képet +@msg `background` must be edges, corners or colors a `background` értéke edges, corners vagy colors lehet +@msg `match` must be distance or channels a `match` értéke distance vagy channels lehet +@msg `{key}` must be from 0 to 100 a `{key}` értéke 0 és 100 között lehet +@msg `colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue a `colors` 1–16 színt sorolhat fel, mindegyiket hexadecimálisan (#rrggbb vagy #rgb) vagy black, white, red, green vagy blue alakban +@msg the linked image shows its preview: embed or relink it first a csatolt kép az előnézetét mutatja: előbb ágyazd be vagy csatold újra +@msg a CMYK image has no transparency to set a CMYK képnek nincs beállítható átlátszósága +@msg every pixel matches the background: no image would be left minden képpont egyezik a háttérrel: nem maradna kép +@msg an image of {w} × {h} pixels is too large to change egy {w} × {h} pixeles kép túl nagy a módosításhoz @msg select an Image Trace object jelölj ki egy képvektorizálási objektumot @msg select an object in perspective jelölj ki egy perspektivikus objektumot @msg select artwork (or a colour group) to recolor jelölj ki átszínezendő grafikát (vagy színcsoportot) diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 002740e11..04581cd72 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -189,6 +189,15 @@ Expand… Perluas… Expand Appearance Perluas tampilan Crop Image Pangkas gambar + Image Tools Alat Gambar + Remove Background… Hapus latar belakang… + Remove Background Hapus latar belakang + Background Latar belakang + Edge Color Warna tepi + Picked Colors Warna pilihan + Corner Color Warna sudut + Contiguous Bersambung + Each Channel Setiap kanal Edit Path Edit jalur Mask Masker Rasterize… Rasterisasi… @@ -1499,6 +1508,8 @@ digits Malayalam Malayalam Scale at Center Skalakan dari tengah Up/Down: Atas/bawah: Left/Right: Kiri/kanan: + Tate-chu-yoko Up/Down Tate-chu-yoko atas/bawah + Tate-chu-yoko Left/Right Tate-chu-yoko kiri/kanan Underline Garis bawah Vertical Scale % Skala vertikal % Axis Sumbu @@ -4466,6 +4477,7 @@ imageTrace Outline Kontur @msg not a stored central record bukan rekaman pusat yang tersimpan @msg not a valid plug-in module: {0} bukan modul plug-in yang valid: {0} @msg not an EMF or WMF file bukan berkas EMF atau WMF +@msg not an HP-GL file bukan berkas HP-GL @msg not found tidak ditemukan @msg not in pattern editing mode tidak dalam mode pengeditan pola @msg finish editing the pattern first selesaikan pengeditan pola terlebih dahulu diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 7f230b10f..82827964f 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -700,6 +700,15 @@ font-technology Variable Variabili Created: Creato: Crop Ritaglia Crop Image Ritaglia immagine + Image Tools Strumenti immagine + Remove Background… Rimuovi sfondo… + Remove Background Rimuovi sfondo + Background Sfondo + Edge Color Colore del bordo + Picked Colors Colori scelti + Corner Color Colore degli angoli + Contiguous Contiguo + Each Channel Ogni canale Edit Path Modifica tracciato Mask Maschera Crop Marks Segni di ritaglio @@ -3571,6 +3580,8 @@ digits Malayalam Malayalam Scale at Center Ridimensiona dal centro Up/Down: Su/giù: Left/Right: Sinistra/destra: + Tate-chu-yoko Up/Down Tate-chu-yoko su/giù + Tate-chu-yoko Left/Right Tate-chu-yoko sinistra/destra Temperature Temperatura Temperature/Tint… Temperatura/tinta… Template Modello @@ -4425,6 +4436,7 @@ axis Both Entrambi @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller la pagina della tavola da disegno {n} misura {size}, più del limite di pagina PDF di 14.400 pt (200 in): viene scritta con una UserUnit di {unit}, quindi le app che ignorano UserUnit la mostrano altrettante volte più piccola @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in la pagina della tavola da disegno {n} misura {size}, più del limite di pagina PDF di 14.400 pt (200 in): le pagine {label} vengono scritte senza UserUnit, in punti, e le app che rispettano il limite tagliano la pagina a 200 in @msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5 non ha UserUnit, che serve a una pagina più lunga di 14.400 pt (200 in) per mantenere le sue dimensioni: il file viene scritto come PDF 1.6 +@msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 PDF 1.4 non ha UserUnit, che serve a una pagina più lunga di 14.400 pt (200 in) per restare entro il limite di pagina di PDF/A-2, di 14.400 unità: il file viene scritto come PDF 1.6 @msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units la pagina della tavola da disegno {n} misura {size}, troppo lunga e stretta per i lati di pagina PDF da 3 a 14.400 unità: viene scritta con una UserUnit di {unit}, che tiene il lato corto a 3 unità o più, e le app che rispettano il limite tagliano il lato lungo a 14.400 unità @msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard i file {label} accettano numeri reali fino a {max}, e la pagina della tavola da disegno {n}, lunga {side} pt, ne richiede di più grandi: scegli PDF 1.6 o successivo senza standard PDF/X, oppure una tavola da disegno più piccola @msg invalid image data for `{0}` dati immagine non validi per `{0}` @@ -4573,6 +4585,7 @@ axis Both Entrambi @msg not a stored central record non è un record centrale archiviato @msg not a valid plug-in module: {0} non è un modulo plug-in valido: {0} @msg not an EMF or WMF file non è un file EMF o WMF +@msg not an HP-GL file non è un file HP-GL @msg not found non trovato @msg not in pattern editing mode non sei in modalità modifica motivo @msg finish editing the pattern first termina prima la modifica del motivo @@ -4652,6 +4665,14 @@ axis Both Entrambi @msg this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset questo ricalco creerebbe più di {max} punti di ancoraggio, più di quanti il documento possa mostrare in modo fluido: aumenta Disturbo, riduci Tracciati o Colori, oppure scegli un preset a fedeltà inferiore @msg select an envelope seleziona un involucro @msg select an image seleziona un'immagine +@msg `background` must be edges, corners or colors `background` deve essere edges, corners o colors +@msg `match` must be distance or channels `match` deve essere distance o channels +@msg `{key}` must be from 0 to 100 `{key}` deve essere compreso tra 0 e 100 +@msg `colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue `colors` deve elencare da 1 a 16 colori, ciascuno in esadecimale (#rrggbb o #rgb) o come black, white, red, green o blue +@msg the linked image shows its preview: embed or relink it first l’immagine collegata mostra la sua anteprima: incorporala o ricollegala prima +@msg a CMYK image has no transparency to set un'immagine CMYK non ha trasparenza da impostare +@msg every pixel matches the background: no image would be left ogni pixel corrisponde allo sfondo: non resterebbe alcuna immagine +@msg an image of {w} × {h} pixels is too large to change un'immagine di {w} × {h} pixel è troppo grande per essere modificata @msg select an object in perspective seleziona un oggetto in prospettiva @msg select artwork (or a colour group) to recolor seleziona la grafica (o un gruppo colori) da ricolorare @msg Recolor completed; {locked} and {hidden} were left unchanged Ricolorazione completata; senza modifiche: {locked} e {hidden} diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 88c9013b2..42a4aa3cf 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -231,6 +231,15 @@ digits Malayalam マラヤーラム数字 Expand… 分割・拡張… Expand Appearance アピアランスを分割 Crop Image 画像の切り抜き + Image Tools 画像ツール + Remove Background… 背景を削除… + Remove Background 背景を削除 + Background 背景 + Edge Color 端の色 + Picked Colors 選択したカラー + Corner Color 角の色 + Contiguous 隣接 + Each Channel 各チャンネル Edit Path パスを編集 Mask マスク Rasterize… ラスタライズ… @@ -3898,6 +3907,8 @@ file-filter Templates テンプレート Scale at Center 中心から拡大・縮小 Up/Down: 上下: Left/Right: 左右: + Tate-chu-yoko Up/Down 縦中横の上下 + Tate-chu-yoko Left/Right 縦中横の左右 Temperature 色温度 Template テンプレート Templates テンプレート @@ -4644,6 +4655,7 @@ swatch Bright Violet ビビッドバイオレット @msg the image's pixels don't match its size 画像のピクセル数がサイズと一致しません @msg {bpp}-bit bitmaps can't be read {bpp} ビットのビットマップは読み込めません @msg not an EMF or WMF file EMF ファイルでも WMF ファイルでもありません +@msg not an HP-GL file HP-GL ファイルではありません @msg its layers use {_1}, which aren't read yet レイヤーで使用されている {_1} はまだ読み込めません @msg the EMF header is damaged EMF ヘッダーが破損しています @msg damaged 破損しています @@ -4660,6 +4672,7 @@ swatch Bright Violet ビビッドバイオレット @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller アートボード {n} のページは {size} で、PDF のページ上限 14,400 pt (200 in) を超えています: UserUnit {unit} で書き出されるため、UserUnit を無視するアプリではその倍率だけ小さく表示されます @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in アートボード {n} のページは {size} で、PDF のページ上限 14,400 pt (200 in) を超えています: {label} のページは UserUnit なしでポイント単位で書き出され、上限を守るアプリではページが 200 in で切り取られます @msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5 には、14,400 pt (200 in) を超えるページがサイズを保つために必要な UserUnit がありません: ファイルは PDF 1.6 として書き出されます +@msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 PDF 1.4 には、14,400 pt (200 in) を超えるページが PDF/A-2 のページ上限 14,400 単位に収まるために必要な UserUnit がありません: ファイルは PDF 1.6 として書き出されます @msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units アートボード {n} のページは {size} で、PDF のページ辺 3 から 14,400 単位には長く細すぎます: 短辺を 3 単位以上に保つ UserUnit {unit} で書き出され、上限を守るアプリは長辺を 14,400 単位で切り取ります @msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard {label} ファイルの実数は {max} までですが、アートボード {n} の {side} pt のページにはそれより大きな値が必要です: PDF/X 規格なしの PDF 1.6 以降を選ぶか、アートボードを小さくしてください @msg page {0} does not exist (the PDF has {1}) ページ {0} は存在しません (この PDF のページ数は {1} です) @@ -4854,6 +4867,14 @@ swatch Bright Violet ビビッドバイオレット @msg no such view 指定された表示が見つかりません @msg printing isn't available here この環境ではプリントできません @msg select an image 画像を選択してください +@msg `background` must be edges, corners or colors `background` は edges、corners または colors にしてください +@msg `match` must be distance or channels `match` は distance または channels にしてください +@msg `{key}` must be from 0 to 100 `{key}` は 0 から 100 までの値にしてください +@msg `colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue `colors` には 16 進数(#rrggbb または #rgb)か black、white、red、green、blue で書いたカラーを 1〜16 個指定してください +@msg the linked image shows its preview: embed or relink it first リンク画像はプレビューを表示しています: 先に埋め込むか再リンクしてください +@msg a CMYK image has no transparency to set CMYK 画像には設定できる透明部分がありません +@msg every pixel matches the background: no image would be left すべてのピクセルが背景に一致します: 画像が残りません +@msg an image of {w} × {h} pixels is too large to change {w} × {h} ピクセルの画像は大きすぎて変更できません @msg the document has no artboards ドキュメントにアートボードがありません @msg tool (a tool id) is required tool (ツール ID) を指定してください @msg unknown tool `{t}` `{t}` は不明なツールです diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 4b7dfdcac..30b0abd6b 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -161,6 +161,15 @@ Expand… გაშლა… Expand Appearance იერსახის გაშლა Crop Image გამოსახულების ჩამოჭრა + Image Tools გამოსახულების ხელსაწყოები + Remove Background… ფონის მოშორება… + Remove Background ფონის მოშორება + Background ფონი + Edge Color კიდის ფერი + Picked Colors არჩეული ფერები + Corner Color კუთხეების ფერი + Contiguous მომიჯნავე + Each Channel თითოეული არხი Mask ნიღაბი Rasterize… რასტრირება… Create Gradient Mesh… გრადიენტული ბადის შექმნა… @@ -1433,6 +1442,8 @@ digits Malayalam მალაიალამური Scale at Center ცენტრიდან მასშტაბირება Up/Down: ზემოთ/ქვემოთ: Left/Right: მარცხნივ/მარჯვნივ: + Tate-chu-yoko Up/Down Tate-chu-yoko ზემოთ/ქვემოთ + Tate-chu-yoko Left/Right Tate-chu-yoko მარცხნივ/მარჯვნივ Underline ხაზგასმული Vertical Scale % ვერტიკალური მასშტაბი % Axis ღერძი @@ -4151,6 +4162,7 @@ font-technology Variable ცვლადი @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller სამუშაო დაფა {n}-ის გვერდი არის {size}, რაც აღემატება PDF-ის გვერდის ლიმიტს 14 400 pt (200 in): ის იწერება UserUnit {unit}-ით, ამიტომ აპები, რომლებიც UserUnit-ს უგულებელყოფენ, მას ამდენჯერ პატარას აჩვენებენ @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in სამუშაო დაფა {n}-ის გვერდი არის {size}, რაც აღემატება PDF-ის გვერდის ლიმიტს 14 400 pt (200 in): {label} გვერდები იწერება UserUnit-ის გარეშე, პუნქტებში, და აპები, რომლებიც ლიმიტს იცავენ, გვერდს 200 in-ზე ჭრიან @msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5-ს არ აქვს UserUnit, რომელიც 14 400 pt-ზე (200 in) გრძელ გვერდს ზომის შესანარჩუნებლად სჭირდება: ფაილი იწერება როგორც PDF 1.6 +@msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 PDF 1.4-ს არ აქვს UserUnit, რომელიც 14 400 pt-ზე (200 in) გრძელ გვერდს PDF/A-2-ის 14 400 ერთეულიანი გვერდის ზღვარში დასარჩენად სჭირდება: ფაილი იწერება როგორც PDF 1.6 @msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units სამუშაო დაფა {n}-ის გვერდი არის {size}, ძალიან გრძელი და ვიწრო PDF-ის 3-დან 14 400 ერთეულამდე გვერდის მხარეებისთვის: ის იწერება UserUnit {unit}-ით, რომელიც მოკლე მხარეს 3 ერთეულზე ან მეტზე ინარჩუნებს, ხოლო ზღვრის დამცველი აპები გრძელ მხარეს 14 400 ერთეულამდე ჭრიან @msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard {label} ფაილები ინახავს ნამდვილ რიცხვებს {max}-მდე, სამუშაო დაფა {n}-ის {side} pt სიგრძის გვერდს კი უფრო დიდი სჭირდება: აირჩიეთ PDF 1.6 ან უფრო ახალი PDF/X სტანდარტის გარეშე, ან უფრო პატარა სამუშაო დაფა @msg invalid image data for `{0}` არასწორი გამოსახულების მონაცემები `{0}`-სთვის @@ -4290,6 +4302,7 @@ font-technology Variable ცვლადი @msg not a stored central record ეს არ არის შენახული ცენტრალური ჩანაწერი @msg not a valid plug-in module: {0} ეს არ არის პლაგინის სწორი მოდული: {0} @msg not an EMF or WMF file ეს არ არის EMF ან WMF ფაილი +@msg not an HP-GL file ეს არ არის HP-GL ფაილი @msg not found ვერ მოიძებნა @msg not in pattern editing mode პატერნის რედაქტირების რეჟიმი არ არის ჩართული @msg finish editing the pattern first ჯერ დაასრულეთ პატერნის რედაქტირება @@ -4357,6 +4370,14 @@ font-technology Variable ცვლადი @msg this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset ეს ვექტორიზაცია {max}-ზე მეტ საყრდენ წერტილს შექმნიდა, რასაც დოკუმენტი შეუფერხებლად ვერ აჩვენებს: გაზარდეთ ხმაური, შეამცირეთ ტრაექტორიები ან ფერები, ან აირჩიეთ ნაკლები სიზუსტის პრესეტი @msg select an envelope მონიშნეთ გარსი @msg select an image მონიშნეთ გამოსახულება +@msg `background` must be edges, corners or colors `background` უნდა იყოს edges, corners ან colors +@msg `match` must be distance or channels `match` უნდა იყოს distance ან channels +@msg `{key}` must be from 0 to 100 `{key}` უნდა იყოს 0-დან 100-მდე +@msg `colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue `colors` უნდა შეიცავდეს 1-დან 16-მდე ფერს, თითოეულს თექვსმეტობით ფორმატში (#rrggbb ან #rgb) ან როგორც black, white, red, green ან blue +@msg the linked image shows its preview: embed or relink it first დაკავშირებული გამოსახულება გადახედვას აჩვენებს: ჯერ ჩააშენეთ ან ხელახლა მიაბით +@msg a CMYK image has no transparency to set CMYK გამოსახულებას არ აქვს დასაყენებელი გამჭვირვალობა +@msg every pixel matches the background: no image would be left ყველა პიქსელი ემთხვევა ფონს: გამოსახულება აღარ დარჩებოდა +@msg an image of {w} × {h} pixels is too large to change {w} × {h} პიქსელის გამოსახულება შესაცვლელად ზედმეტად დიდია @msg select an object in perspective მონიშნეთ ობიექტი პერსპექტივაში @msg select artwork (or a colour group) to recolor მონიშნეთ ხელახლა შესაფერადებელი ნახატი (ან ფერთა ჯგუფი) @msg Recolor completed; {locked} and {hidden} were left unchanged ხელახლა შეფერადება დასრულდა; უცვლელი დარჩა: {locked} და {hidden} diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index ac3f3d0cf..3bc8360b4 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -224,6 +224,15 @@ digits Malayalam 말라얄람어 Expand… 확장… Expand Appearance 모양새 확장 Crop Image 이미지 자르기 + Image Tools 이미지 도구 + Remove Background… 배경 제거… + Remove Background 배경 제거 + Background 배경 + Edge Color 가장자리 색상 + Picked Colors 선택한 색상 + Corner Color 모퉁이 색상 + Contiguous 인접 + Each Channel 각 채널 Edit Path 패스 편집 Mask 마스크 Rasterize… 래스터화… @@ -3886,6 +3895,8 @@ file-filter Templates 템플릿 Scale at Center 중심에서 크기 조정 Up/Down: 위/아래: Left/Right: 왼쪽/오른쪽: + Tate-chu-yoko Up/Down 다테추요코 위/아래 + Tate-chu-yoko Left/Right 다테추요코 왼쪽/오른쪽 Temperature 색온도 Template 템플릿 Templates 템플릿 @@ -4571,6 +4582,7 @@ theme Appearance 외관 @msg the image's pixels don't match its size 이미지의 픽셀이 크기와 맞지 않습니다 @msg {bpp}-bit bitmaps can't be read {bpp}비트 비트맵은 읽을 수 없습니다 @msg not an EMF or WMF file EMF 또는 WMF 파일이 아닙니다 +@msg not an HP-GL file HP-GL 파일이 아닙니다 @msg its layers use {_1}, which aren't read yet 레이어에 {_1}이(가) 있으며, 아직 읽을 수 없습니다 @msg the EMF header is damaged EMF 헤더가 손상되었습니다 @msg damaged 손상됨 @@ -4587,6 +4599,7 @@ theme Appearance 외관 @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller 아트보드 {n}의 페이지는 {size}로 PDF 페이지 한도 14,400 pt (200 in)보다 깁니다: UserUnit {unit}(으)로 기록되므로 UserUnit을 무시하는 앱에서는 그 배수만큼 작게 표시됩니다 @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in 아트보드 {n}의 페이지는 {size}로 PDF 페이지 한도 14,400 pt (200 in)보다 깁니다: {label} 페이지는 UserUnit 없이 포인트 단위로 기록되며, 한도를 지키는 앱은 페이지를 200 in으로 자릅니다 @msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5에는 14,400 pt (200 in)보다 긴 페이지가 크기를 유지하는 데 필요한 UserUnit이 없습니다: 파일이 PDF 1.6으로 기록됩니다 +@msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 PDF 1.4에는 14,400 pt (200 in)보다 긴 페이지가 PDF/A-2의 페이지 한도인 14,400 단위 안에 들어가는 데 필요한 UserUnit이 없습니다: 파일이 PDF 1.6으로 기록됩니다 @msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units 아트보드 {n}의 페이지는 {size}로, PDF 페이지 변 3에서 14,400 단위에 비해 너무 길고 좁습니다: 짧은 변을 3 단위 이상으로 유지하는 UserUnit {unit}로 기록되며, 한도를 지키는 앱은 긴 변을 14,400 단위로 자릅니다 @msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard {label} 파일은 실수를 최대 {max}까지 담는데 아트보드 {n}의 {side} pt 페이지에는 더 큰 값이 필요합니다: PDF/X 표준 없이 PDF 1.6 이상을 선택하거나 더 작은 아트보드를 사용하세요 @msg page {0} does not exist (the PDF has {1}) 페이지 {0}이(가) 없습니다(PDF의 페이지 수: {1}) @@ -4781,6 +4794,14 @@ theme Appearance 외관 @msg no such view 해당 보기가 없습니다 @msg printing isn't available here 여기서는 인쇄할 수 없습니다 @msg select an image 이미지를 선택하세요 +@msg `background` must be edges, corners or colors `background`는 edges, corners 또는 colors여야 합니다 +@msg `match` must be distance or channels `match`는 distance 또는 channels여야 합니다 +@msg `{key}` must be from 0 to 100 `{key}`는 0에서 100 사이여야 합니다 +@msg `colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue `colors`에는 16진수(#rrggbb 또는 #rgb)나 black, white, red, green, blue로 쓴 색상이 1~16개 있어야 합니다 +@msg the linked image shows its preview: embed or relink it first 연결된 이미지가 미리 보기를 표시하고 있습니다: 먼저 포함하거나 다시 링크하세요 +@msg a CMYK image has no transparency to set CMYK 이미지에는 설정할 투명도가 없습니다 +@msg every pixel matches the background: no image would be left 모든 픽셀이 배경과 일치합니다: 남는 이미지가 없습니다 +@msg an image of {w} × {h} pixels is too large to change {w} × {h}픽셀 이미지는 너무 커서 변경할 수 없습니다 @msg the document has no artboards 문서에 아트보드가 없습니다 @msg tool (a tool id) is required 도구(도구 id)가 필요합니다 @msg unknown tool `{t}` 알 수 없는 도구 `{t}` diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 062215695..c6f37b432 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -203,6 +203,15 @@ Expand… Uitbreiden… Expand Appearance Uiterlijk uitbreiden Crop Image Afbeelding bijsnijden + Image Tools Afbeeldingshulpmiddelen + Remove Background… Achtergrond verwijderen… + Remove Background Achtergrond verwijderen + Background Achtergrond + Edge Color Randkleur + Picked Colors Gekozen kleuren + Corner Color Hoekkleur + Contiguous Aaneengesloten + Each Channel Elk kanaal Edit Path Pad bewerken Mask Masker Rasterize… Rasteriseren… @@ -1513,6 +1522,8 @@ digits Malayalam Malayalam Scale at Center Schalen vanuit het midden Up/Down: Omhoog/omlaag: Left/Right: Links/rechts: + Tate-chu-yoko Up/Down Tate-chu-yoko omhoog/omlaag + Tate-chu-yoko Left/Right Tate-chu-yoko links/rechts Underline Onderstrepen Vertical Scale % Verticale schaal % Axis As @@ -4478,6 +4489,7 @@ imageTrace Outline Contour @msg not a stored central record geen opgeslagen centraal record @msg not a valid plug-in module: {0} geen geldige plug-inmodule: {0} @msg not an EMF or WMF file geen EMF- of WMF-bestand +@msg not an HP-GL file geen HP-GL-bestand @msg not found niet gevonden @msg not in pattern editing mode niet in patroonbewerkingsmodus @msg finish editing the pattern first rond eerst het bewerken van het patroon af diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 11dd0dc99..3647b56f5 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -166,6 +166,15 @@ Expand… Rozszerz… Expand Appearance Rozszerz wygląd Crop Image Kadruj obraz + Image Tools Narzędzia obrazu + Remove Background… Usuń tło… + Remove Background Usuń tło + Background Tło + Edge Color Kolor krawędzi + Picked Colors Wybrane kolory + Corner Color Kolor narożników + Contiguous Ciągły + Each Channel Każdy kanał Edit Path Edytuj ścieżkę Mask Maska Rasterize… Rasteryzuj… @@ -1500,6 +1509,8 @@ digits Malayalam Malajalam Scale at Center Skaluj od środka Up/Down: W górę/w dół: Left/Right: W lewo/w prawo: + Tate-chu-yoko Up/Down Tate-chu-yoko w górę/w dół + Tate-chu-yoko Left/Right Tate-chu-yoko w lewo/w prawo Underline Podkreślenie Vertical Scale % Skala pionowa % Axis Oś @@ -4514,6 +4525,7 @@ imageTrace Outline Kontur @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller strona obszaru kompozycji {n} ma {size}, czyli więcej niż limit strony PDF wynoszący 14 400 pt (200 in): jest zapisywana z UserUnit {unit}, więc aplikacje ignorujące UserUnit pokazują ją tyle razy mniejszą @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in strona obszaru kompozycji {n} ma {size}, czyli więcej niż limit strony PDF wynoszący 14 400 pt (200 in): strony {label} są zapisywane bez UserUnit, w punktach, a aplikacje przestrzegające limitu przycinają stronę do 200 in @msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5 nie ma UserUnit, którego strona dłuższa niż 14 400 pt (200 in) potrzebuje, aby zachować rozmiar: plik jest zapisywany jako PDF 1.6 +@msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 PDF 1.4 nie ma UserUnit, którego strona dłuższa niż 14 400 pt (200 in) potrzebuje, aby zmieścić się w limicie strony PDF/A-2 wynoszącym 14 400 jednostek: plik jest zapisywany jako PDF 1.6 @msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units strona obszaru kompozycji {n} ma {size} i jest za długa i za wąska dla boków strony PDF od 3 do 14 400 jednostek: jest zapisywana z UserUnit {unit}, która utrzymuje krótszy bok na co najmniej 3 jednostkach, a aplikacje przestrzegające limitu przycinają dłuższy bok do 14 400 jednostek @msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard pliki {label} mieszczą liczby rzeczywiste do {max}, a strona obszaru kompozycji {n} o długości {side} pt wymaga większych: wybierz PDF 1.6 lub nowszy bez standardu PDF/X albo mniejszy obszar kompozycji @msg invalid image data for `{0}` nieprawidłowe dane obrazu dla `{0}` @@ -4663,6 +4675,7 @@ imageTrace Outline Kontur @msg not a stored central record to nie jest zapisany rekord centralny @msg not a valid plug-in module: {0} nieprawidłowy moduł wtyczki: {0} @msg not an EMF or WMF file to nie jest plik EMF ani WMF +@msg not an HP-GL file to nie jest plik HP-GL @msg not found nie znaleziono @msg not in pattern editing mode nie jest w trybie edycji wzorka @msg finish editing the pattern first najpierw zakończ edycję wzorka @@ -4743,6 +4756,14 @@ imageTrace Outline Kontur @msg this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset ten obrys utworzyłby ponad {max} punktów kontrolnych, więcej niż dokument może płynnie wyświetlić: zwiększ Szum, zmniejsz Ścieżki lub Kolory albo wybierz ustawienie o niższej wierności @msg select an envelope zaznacz kopertę @msg select an image zaznacz obraz +@msg `background` must be edges, corners or colors `background` musi mieć wartość edges, corners lub colors +@msg `match` must be distance or channels `match` musi mieć wartość distance lub channels +@msg `{key}` must be from 0 to 100 `{key}` musi mieścić się w zakresie od 0 do 100 +@msg `colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue `colors` musi zawierać od 1 do 16 kolorów, każdy szesnastkowo (#rrggbb lub #rgb) albo jako black, white, red, green lub blue +@msg the linked image shows its preview: embed or relink it first połączony obraz pokazuje podgląd: najpierw go osadź lub połącz ponownie +@msg a CMYK image has no transparency to set obraz CMYK nie ma przezroczystości, którą można ustawić +@msg every pixel matches the background: no image would be left każdy piksel pasuje do tła: nie zostałby żaden obraz +@msg an image of {w} × {h} pixels is too large to change obraz o wymiarach {w} × {h} pikseli jest zbyt duży, aby go zmienić @msg select an object in perspective zaznacz obiekt w perspektywie @msg select artwork (or a colour group) to recolor zaznacz kompozycję (lub grupę kolorów), aby zmienić kolory @msg Recolor completed; {locked} and {hidden} were left unchanged Zmiana kolorów zakończona; bez zmian: {locked} i {hidden} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 62b2fa772..530a62719 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -626,6 +626,15 @@ antiAlias Sharp Nítido Created: Criado: Crop Recortar Crop Image Recortar Imagem + Image Tools Ferramentas de imagem + Remove Background… Remover Fundo… + Remove Background Remover Fundo + Background Fundo + Edge Color Cor da Borda + Picked Colors Cores Escolhidas + Corner Color Cor dos Cantos + Contiguous Contíguo + Each Channel Cada Canal Edit Path Editar caminho Mask Máscara Crop Marks Marcas de Corte @@ -3924,6 +3933,7 @@ axis Both Ambos @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller a página da prancheta {n} tem {size}, mais que o limite de página do PDF de 14.400 pt (200 in): ela é gravada com um UserUnit de {unit}, então apps que ignoram UserUnit a mostram essa quantidade de vezes menor @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in a página da prancheta {n} tem {size}, mais que o limite de página do PDF de 14.400 pt (200 in): páginas {label} são gravadas sem UserUnit, em pontos, e apps que respeitam o limite cortam a página em 200 in @msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 o PDF 1.5 não tem UserUnit, de que uma página com mais de 14.400 pt (200 in) precisa para manter seu tamanho: o arquivo é gravado como PDF 1.6 +@msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 o PDF 1.4 não tem UserUnit, de que uma página com mais de 14.400 pt (200 in) precisa para ficar dentro do limite de página do PDF/A-2, de 14.400 unidades: o arquivo é gravado como PDF 1.6 @msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units a página da prancheta {n} tem {size}, longa e estreita demais para os lados de página do PDF, de 3 a 14.400 unidades: ela é gravada com uma UserUnit de {unit}, que mantém o lado menor em 3 unidades ou mais, e os apps que seguem o limite cortam o lado maior em 14.400 unidades @msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard os arquivos {label} aceitam números reais de até {max}, e a página da prancheta {n}, com {side} pt, precisa de números maiores: escolha PDF 1.6 ou posterior sem padrão PDF/X, ou uma prancheta menor @msg invalid image data for `{0}` dados de imagem inválidos para `{0}` @@ -4071,6 +4081,7 @@ axis Both Ambos @msg not a stored central record não é um registro central armazenado @msg not a valid plug-in module: {0} não é um módulo de plug-in válido: {0} @msg not an EMF or WMF file não é um arquivo EMF ou WMF +@msg not an HP-GL file não é um arquivo HP-GL @msg not found não encontrado @msg not in pattern editing mode não está no modo de edição de padrão @msg finish editing the pattern first termine de editar o padrão primeiro @@ -4149,6 +4160,14 @@ axis Both Ambos @msg this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset esta vetorização geraria mais de {max} pontos de ancoragem, mais do que o documento consegue exibir com fluidez: aumente Ruído, reduza Traçados ou Cores, ou escolha uma predefinição de menor fidelidade @msg select an envelope selecione um envelope @msg select an image selecione uma imagem +@msg `background` must be edges, corners or colors `background` deve ser edges, corners ou colors +@msg `match` must be distance or channels `match` deve ser distance ou channels +@msg `{key}` must be from 0 to 100 `{key}` deve estar entre 0 e 100 +@msg `colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue `colors` deve listar de 1 a 16 cores, cada uma em hexadecimal (#rrggbb ou #rgb) ou como black, white, red, green ou blue +@msg the linked image shows its preview: embed or relink it first a imagem vinculada mostra sua prévia: incorpore-a ou revincule-a primeiro +@msg a CMYK image has no transparency to set uma imagem CMYK não tem transparência para definir +@msg every pixel matches the background: no image would be left todos os pixels correspondem ao fundo: nenhuma imagem restaria +@msg an image of {w} × {h} pixels is too large to change uma imagem de {w} × {h} pixels é grande demais para ser alterada @msg select an object in perspective selecione um objeto em perspectiva @msg select artwork (or a colour group) to recolor selecione uma arte-final (ou um grupo de cores) para recolorir @msg Recolor completed; {locked} and {hidden} were left unchanged Recoloração concluída; sem alteração: {locked} e {hidden} @@ -5048,6 +5067,8 @@ axis Both Ambos Scale at Center Dimensionar a partir do centro Up/Down: Para Cima/Para Baixo: Left/Right: Esquerda/Direita: + Tate-chu-yoko Up/Down Tate-chu-yoko Para Cima/Para Baixo + Tate-chu-yoko Left/Right Tate-chu-yoko Esquerda/Direita Temperature Temperatura Templates Modelos Text Wrap Options Opções de Quebra de Texto diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 87c359b83..341dc196f 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -698,6 +698,15 @@ font-technology Variable Вариативные Created: Создано: Crop Обрезать Crop Image Обрезать изображение + Image Tools Инструменты изображения + Remove Background… Удалить фон… + Remove Background Удалить фон + Background Фон + Edge Color Цвет края + Picked Colors Выбранные цвета + Corner Color Цвет углов + Contiguous Смежные + Each Channel Каждый канал Edit Path Редактировать контур Mask Маска Crop Marks Метки обрезки @@ -3566,6 +3575,8 @@ digits Malayalam Малаялам Scale at Center Масштабировать от центра Up/Down: Вверх/вниз: Left/Right: Влево/вправо: + Tate-chu-yoko Up/Down Tate-chu-yoko вверх/вниз + Tate-chu-yoko Left/Right Tate-chu-yoko влево/вправо Temperature Температура Temperature/Tint… Температура/оттенок… Template Шаблон @@ -4419,6 +4430,7 @@ axis Both Оба @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller страница монтажной области {n} имеет размер {size}, что больше ограничения PDF на размер страницы в 14 400 pt (200 in): она записывается с UserUnit {unit}, поэтому приложения, которые игнорируют UserUnit, показывают её во столько же раз меньше @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in страница монтажной области {n} имеет размер {size}, что больше ограничения PDF на размер страницы в 14 400 pt (200 in): страницы {label} записываются без UserUnit, в пунктах, и приложения, соблюдающие ограничение, обрезают страницу до 200 in @msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 в PDF 1.5 нет UserUnit, который нужен странице длиннее 14 400 pt (200 in), чтобы сохранить размер: файл записывается как PDF 1.6 +@msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 в PDF 1.4 нет UserUnit, который нужен странице длиннее 14 400 pt (200 in), чтобы уложиться в предел страницы PDF/A-2 в 14 400 единиц: файл записывается как PDF 1.6 @msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units страница монтажной области {n} имеет размер {size} и слишком длинная и узкая для сторон страницы PDF от 3 до 14 400 единиц: она записывается с UserUnit {unit}, которая держит короткую сторону не меньше 3 единиц, а приложения, соблюдающие предел, обрезают длинную сторону до 14 400 единиц @msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard файлы {label} вмещают вещественные числа до {max}, а странице монтажной области {n} длиной {side} pt нужны большие: выберите PDF 1.6 или новее без стандарта PDF/X или монтажную область меньшего размера @msg invalid image data for `{0}` недопустимые данные изображения для `{0}` @@ -4567,6 +4579,7 @@ axis Both Оба @msg not a stored central record не является сохранённой центральной записью @msg not a valid plug-in module: {0} не является допустимым модулем: {0} @msg not an EMF or WMF file не является файлом EMF или WMF +@msg not an HP-GL file не является файлом HP-GL @msg not found не найдено @msg not in pattern editing mode не в режиме редактирования узора @msg finish editing the pattern first сначала завершите редактирование узора @@ -4646,6 +4659,14 @@ axis Both Оба @msg this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset эта трассировка создала бы более {max} опорных точек, что больше, чем документ может плавно отобразить: увеличьте «Шум», уменьшите «Контуры» или «Цвета» либо выберите набор с меньшей точностью @msg select an envelope выделите оболочку @msg select an image выделите изображение +@msg `background` must be edges, corners or colors `background` должен быть edges, corners или colors +@msg `match` must be distance or channels `match` должен быть distance или channels +@msg `{key}` must be from 0 to 100 `{key}` должен быть от 0 до 100 +@msg `colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue `colors` должен содержать от 1 до 16 цветов, каждый в шестнадцатеричном виде (#rrggbb или #rgb) или как black, white, red, green или blue +@msg the linked image shows its preview: embed or relink it first связанное изображение показывает предпросмотр: сначала внедрите его или восстановите связь +@msg a CMYK image has no transparency to set у изображения CMYK нет прозрачности, которую можно задать +@msg every pixel matches the background: no image would be left каждый пиксель совпадает с фоном: от изображения ничего не осталось бы +@msg an image of {w} × {h} pixels is too large to change изображение размером {w} × {h} пикселей слишком велико для изменения @msg select an object in perspective выделите объект в перспективе @msg select artwork (or a colour group) to recolor выделите рисунок (или цветовую группу) для перекрашивания @msg Recolor completed; {locked} and {hidden} were left unchanged Перекрашивание завершено; без изменений: {locked} и {hidden} diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 92de527ff..ed59d9811 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -688,6 +688,15 @@ font-technology Variable Variabla Created: Skapad: Crop Beskär Crop Image Beskär bild + Image Tools Bildverktyg + Remove Background… Ta bort bakgrund… + Remove Background Ta bort bakgrund + Background Bakgrund + Edge Color Kantfärg + Picked Colors Valda färger + Corner Color Hörnfärg + Contiguous Sammanhängande + Each Channel Varje kanal Edit Path Redigera bana Mask Maskera Crop Marks Skärmärken @@ -3456,6 +3465,8 @@ digits Malayalam Malayalam Scale at Center Skala från mitten Up/Down: Upp/ned: Left/Right: Vänster/höger: + Tate-chu-yoko Up/Down Tate-chu-yoko upp/ned + Tate-chu-yoko Left/Right Tate-chu-yoko vänster/höger Temperature Temperatur Temperature/Tint… Temperatur/färgton… Template Mall @@ -4286,6 +4297,7 @@ axis Both Båda @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller sidan för rityta {n} är {size}, längre än PDF:s sidgräns på 14 400 pt (200 in): den skrivs med en UserUnit på {unit}, så appar som ignorerar UserUnit visar den så många gånger mindre @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in sidan för rityta {n} är {size}, längre än PDF:s sidgräns på 14 400 pt (200 in): {label}-sidor skrivs utan UserUnit, i punkter, och appar som håller sig till gränsen beskär sidan till 200 in @msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5 har ingen UserUnit, som en sida längre än 14 400 pt (200 in) behöver för att behålla sin storlek: filen skrivs som PDF 1.6 +@msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 PDF 1.4 har ingen UserUnit, som en sida längre än 14 400 pt (200 in) behöver för att hålla sig inom PDF/A-2:s sidgräns på 14 400 enheter: filen skrivs som PDF 1.6 @msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units sidan för rityta {n} är {size}, för lång och smal för PDF:s sidlängder på 3 till 14 400 enheter: den skrivs med UserUnit {unit}, som håller den korta sidan på minst 3 enheter, och appar som följer gränsen klipper den långa sidan vid 14 400 enheter @msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard {label}-filer rymmer reella tal upp till {max}, och sidan för rityta {n}, {side} pt lång, behöver större: välj PDF 1.6 eller senare utan PDF/X-standard, eller en mindre rityta @msg invalid image data for `{0}` ogiltiga bilddata för `{0}` @@ -4434,6 +4446,7 @@ axis Both Båda @msg not a stored central record inte en lagrad central post @msg not a valid plug-in module: {0} inte ett giltigt plugin: {0} @msg not an EMF or WMF file inte en EMF- eller WMF-fil +@msg not an HP-GL file inte en HP-GL-fil @msg not found hittades inte @msg not in pattern editing mode inte i läget för mönsterredigering @msg finish editing the pattern first slutför redigeringen av mönstret först @@ -4507,6 +4520,14 @@ axis Both Båda @msg this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset spårningen skulle ge fler än {max} ankarpunkter, fler än dokumentet kan visa smidigt: höj Brus, sänk Banor eller Färger, eller välj en förinställning med lägre återgivning @msg select an envelope markera ett kuvert @msg select an image markera en bild +@msg `background` must be edges, corners or colors `background` måste vara edges, corners eller colors +@msg `match` must be distance or channels `match` måste vara distance eller channels +@msg `{key}` must be from 0 to 100 `{key}` måste vara från 0 till 100 +@msg `colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue `colors` måste lista 1 till 16 färger, var och en hexadecimalt (#rrggbb eller #rgb) eller som black, white, red, green eller blue +@msg the linked image shows its preview: embed or relink it first den länkade bilden visar sin förhandsvisning: bädda in eller länka om den först +@msg a CMYK image has no transparency to set en CMYK-bild har ingen transparens att ställa in +@msg every pixel matches the background: no image would be left varje pixel matchar bakgrunden: ingen bild skulle bli kvar +@msg an image of {w} × {h} pixels is too large to change en bild på {w} × {h} pixlar är för stor för att ändras @msg select an object in perspective markera ett objekt i perspektiv @msg select artwork (or a colour group) to recolor markera grafik (eller en färggrupp) att färga om @msg Recolor completed; {locked} and {hidden} were left unchanged Omfärgning klar; utan ändring: {locked} och {hidden} diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index e2ec9c685..dc89f3380 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -680,6 +680,15 @@ font-technology Variable Değişken Created: Oluşturuldu: Crop Kırpma Crop Image Resmi Kırp + Image Tools Resim Araçları + Remove Background… Arka Planı Kaldır… + Remove Background Arka Planı Kaldır + Background Arka Plan + Edge Color Kenar Rengi + Picked Colors Seçilen Renkler + Corner Color Köşe Rengi + Contiguous Bitişik + Each Channel Her Kanal Mask Maske Crop Marks Kırpma İşaretleri Crop To Kırpma Hedefi @@ -3347,6 +3356,8 @@ digits Malayalam Malayalam Scale at Center Merkezden Ölçekle Up/Down: Yukarı/Aşağı: Left/Right: Sol/Sağ: + Tate-chu-yoko Up/Down Tate-chu-yoko Yukarı/Aşağı + Tate-chu-yoko Left/Right Tate-chu-yoko Sol/Sağ Temperature Sıcaklık Temperature/Tint… Sıcaklık/Renk Tonu… Template Şablon @@ -4145,6 +4156,7 @@ axis Both Her İkisi @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller {n} çalışma yüzeyinin sayfası {size}, PDF'nin 14.400 pt (200 in) sayfa sınırından uzun: {unit} UserUnit değeriyle yazılır, bu yüzden UserUnit'i yok sayan uygulamalar onu o kadar kat küçük gösterir @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in {n} çalışma yüzeyinin sayfası {size}, PDF'nin 14.400 pt (200 in) sayfa sınırından uzun: {label} sayfaları UserUnit olmadan, punto cinsinden yazılır ve sınıra uyan uygulamalar sayfayı 200 in'de keser @msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5'te, 14.400 pt (200 in) uzunluğunu aşan bir sayfanın boyutunu koruması için gereken UserUnit yoktur: dosya PDF 1.6 olarak yazılır +@msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 PDF 1.4'te, 14.400 pt (200 in) uzunluğunu aşan bir sayfanın PDF/A-2'nin 14.400 birimlik sayfa sınırı içinde kalması için gereken UserUnit yoktur: dosya PDF 1.6 olarak yazılır @msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units {n} çalışma yüzeyinin sayfası {size}, PDF'nin 3 ile 14.400 birim arasındaki sayfa kenarları için fazla uzun ve dar: kısa kenarı en az 3 birimde tutan {unit} UserUnit değeriyle yazılır ve sınıra uyan uygulamalar uzun kenarı 14.400 birimde keser @msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard {label} dosyaları en fazla {max} değerinde gerçek sayı tutar ve {n} çalışma yüzeyinin {side} pt uzunluğundaki sayfası daha büyüklerini gerektiriyor: PDF/X standardı olmadan PDF 1.6 veya sonrasını ya da daha küçük bir çalışma yüzeyi seçin @msg invalid image data for `{0}` `{0}` için geçersiz resim verileri @@ -4288,6 +4300,7 @@ axis Both Her İkisi @msg not a stored central record saklanan merkezi bir kayıt değil @msg not a valid plug-in module: {0} geçerli bir eklenti modülü değil: {0} @msg not an EMF or WMF file EMF veya WMF dosyası değil +@msg not an HP-GL file HP-GL dosyası değil @msg not an RTF file: it doesn't start with {\\rtf bir RTF dosyası değil: {\\rtf ile başlamıyor @msg not found bulunamadı @msg not in pattern editing mode desen düzenleme modunda değil @@ -4359,6 +4372,14 @@ axis Both Her İkisi @msg this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset bu iz, {max} bağlantı noktalarından daha fazlasını, yani belgenin düzgün bir şekilde gösterebileceğinden daha fazlasını oluşturur: Gürültüyü artırın, Yolları veya Renkleri düşürün veya aslına uygunluğu daha düşük bir ön ayar seçin @msg select an envelope bir zarf seç @msg select an image bir resim seç +@msg `background` must be edges, corners or colors `background` edges, corners veya colors olmalıdır +@msg `match` must be distance or channels `match` distance veya channels olmalıdır +@msg `{key}` must be from 0 to 100 `{key}` 0 ile 100 arasında olmalıdır +@msg `colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue `colors`, her biri onaltılık (#rrggbb veya #rgb) ya da black, white, red, green veya blue olarak yazılmış 1 ile 16 arasında renk içermelidir +@msg the linked image shows its preview: embed or relink it first bağlantılı resim önizlemesini gösteriyor: önce gömün veya yeniden bağlayın +@msg a CMYK image has no transparency to set CMYK görüntüsünde ayarlanacak saydamlık yok +@msg every pixel matches the background: no image would be left her piksel arka planla eşleşiyor: hiç görüntü kalmaz +@msg an image of {w} × {h} pixels is too large to change {w} × {h} piksellik bir görüntü değiştirilemeyecek kadar büyük @msg select an object in perspective perspektifte bir nesne seç @msg select artwork (or a colour group) to recolor yeniden renklendirilecek resmi (veya bir renk grubunu) seçin @msg Recolor completed; {locked} and {hidden} were left unchanged Yeniden renklendirme tamamlandı; değiştirilmeden kalan: {locked} ve {hidden} diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 235a4d389..8d67846ec 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -694,6 +694,15 @@ font-technology Variable Варіативні Created: Створено: Crop Обрізати Crop Image Обрізати зображення + Image Tools Інструменти зображення + Remove Background… Видалити фон… + Remove Background Видалити фон + Background Фон + Edge Color Колір краю + Picked Colors Вибрані кольори + Corner Color Колір кутів + Contiguous Суміжні + Each Channel Кожен канал Edit Path Редагувати контур Mask Маска Crop Marks Мітки обрізання @@ -3564,6 +3573,8 @@ effect Points Точки Scale at Center Масштабувати від центру Up/Down: Вгору/вниз: Left/Right: Ліворуч/праворуч: + Tate-chu-yoko Up/Down Tate-chu-yoko вгору/вниз + Tate-chu-yoko Left/Right Tate-chu-yoko ліворуч/праворуч Temperature Температура Temperature/Tint… Температура/відтінок… Template Шаблон @@ -4409,6 +4420,7 @@ axis Both Обидві @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller сторінка монтажної області {n} має розмір {size}, що більше за обмеження PDF на розмір сторінки в 14 400 pt (200 in): її записано з UserUnit {unit}, тому застосунки, які ігнорують UserUnit, показують її в стільки ж разів меншою @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in сторінка монтажної області {n} має розмір {size}, що більше за обмеження PDF на розмір сторінки в 14 400 pt (200 in): сторінки {label} записуються без UserUnit, у пунктах, і застосунки, що дотримуються обмеження, обрізають сторінку до 200 in @msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 у PDF 1.5 немає UserUnit, який потрібен сторінці, довшій за 14 400 pt (200 in), щоб зберегти розмір: файл записується як PDF 1.6 +@msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 у PDF 1.4 немає UserUnit, який потрібен сторінці, довшій за 14 400 pt (200 in), щоб вкластися в межу сторінки PDF/A-2 у 14 400 одиниць: файл записується як PDF 1.6 @msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units сторінка монтажної області {n} має розмір {size} і задовга та завузька для сторін сторінки PDF від 3 до 14 400 одиниць: її записано з UserUnit {unit}, що тримає коротку сторону не менше 3 одиниць, а застосунки, які дотримуються межі, обрізають довгу сторону до 14 400 одиниць @msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard файли {label} вміщують дійсні числа до {max}, а сторінці монтажної області {n} завдовжки {side} pt потрібні більші: виберіть PDF 1.6 або новіший без стандарту PDF/X чи меншу монтажну область @msg invalid image data for `{0}` неприпустимі дані зображення для `{0}` @@ -4557,6 +4569,7 @@ axis Both Обидві @msg not a stored central record не є збереженим центральним записом @msg not a valid plug-in module: {0} не є допустимим модулем: {0} @msg not an EMF or WMF file не є файлом EMF або WMF +@msg not an HP-GL file не є файлом HP-GL @msg not found не знайдено @msg not in pattern editing mode не в режимі редагування візерунка @msg finish editing the pattern first спочатку завершіть редагування візерунка @@ -4636,6 +4649,14 @@ axis Both Обидві @msg this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset це трасування створило б більше {max} опорних точок, що більше, ніж документ може плавно відобразити: збільште «Шум», зменште «Контури» або «Кольори» або виберіть набір з меншою точністю @msg select an envelope виділіть оболонку @msg select an image виділіть зображення +@msg `background` must be edges, corners or colors `background` має бути edges, corners або colors +@msg `match` must be distance or channels `match` має бути distance або channels +@msg `{key}` must be from 0 to 100 `{key}` має бути від 0 до 100 +@msg `colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue `colors` має містити від 1 до 16 кольорів, кожен у шістнадцятковому вигляді (#rrggbb або #rgb) або як black, white, red, green чи blue +@msg the linked image shows its preview: embed or relink it first пов’язане зображення показує попередній перегляд: спочатку вбудуйте його або відновіть зв’язок +@msg a CMYK image has no transparency to set зображення CMYK не має прозорості, яку можна задати +@msg every pixel matches the background: no image would be left кожен піксель збігається з фоном: від зображення нічого не залишилося б +@msg an image of {w} × {h} pixels is too large to change зображення розміром {w} × {h} пікселів завелике для зміни @msg select an object in perspective виділіть об’єкт у перспективі @msg select artwork (or a colour group) to recolor виділіть малюнок (або колірну групу) для перефарбовування @msg Recolor completed; {locked} and {hidden} were left unchanged Перефарбовування завершено; без змін: {locked} і {hidden} diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 87d2c2262..8f1ea434f 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -801,6 +801,15 @@ font-technology Variable Biến thiên Created: Ngày tạo: Crop Cắt xén Crop Image Cắt xén ảnh + Image Tools Công cụ hình ảnh + Remove Background… Xóa nền… + Remove Background Xóa nền + Background Nền + Edge Color Màu cạnh + Picked Colors Màu đã chọn + Corner Color Màu góc + Contiguous Liền kề + Each Channel Từng kênh Edit Path Sửa đường dẫn Crop Marks Dấu cắt xén Crop To Cắt xén đến @@ -3905,6 +3914,8 @@ mark-style Japanese Kiểu Nhật Scale at Center Co giãn từ tâm Up/Down: Lên/Xuống: Left/Right: Trái/Phải: + Tate-chu-yoko Up/Down Tate-chu-yoko lên/xuống + Tate-chu-yoko Left/Right Tate-chu-yoko trái/phải Temperature Nhiệt độ màu Temperature/Tint… Nhiệt độ màu/Tông… Template Mẫu @@ -4668,6 +4679,7 @@ theme Appearance Giao diện @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller trang của bảng vẽ {n} có kích thước {size}, dài hơn giới hạn trang PDF 14.400 pt (200 in): trang được ghi với UserUnit là {unit}, nên các ứng dụng bỏ qua UserUnit sẽ hiển thị nó nhỏ đi bấy nhiêu lần @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in trang của bảng vẽ {n} có kích thước {size}, dài hơn giới hạn trang PDF 14.400 pt (200 in): các trang {label} được ghi không có UserUnit, theo đơn vị điểm, và các ứng dụng tuân theo giới hạn sẽ cắt trang ở 200 in @msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5 không có UserUnit, thứ mà trang dài hơn 14.400 pt (200 in) cần để giữ kích thước: tệp được ghi dưới dạng PDF 1.6 +@msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 PDF 1.4 không có UserUnit, thứ mà trang dài hơn 14.400 pt (200 in) cần để nằm trong giới hạn trang 14.400 đơn vị của PDF/A-2: tệp được ghi dưới dạng PDF 1.6 @msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units trang của bảng vẽ {n} có kích thước {size}, quá dài và hẹp so với cạnh trang PDF từ 3 đến 14.400 đơn vị: trang được ghi với UserUnit {unit}, giữ cạnh ngắn từ 3 đơn vị trở lên, và các ứng dụng tuân theo giới hạn sẽ cắt cạnh dài ở 14.400 đơn vị @msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard tệp {label} chỉ chứa số thực tối đa {max}, còn trang của bảng vẽ {n} dài {side} pt cần số lớn hơn: hãy chọn PDF 1.6 trở lên không theo tiêu chuẩn PDF/X, hoặc một bảng vẽ nhỏ hơn @msg invalid restored panel group nhóm bảng được phục hồi không hợp lệ @@ -4870,6 +4882,7 @@ theme Appearance Giao diện @msg not a shortcut set: the text doesn't start with a slash name and an opening brace không phải bộ phím tắt: văn bản không bắt đầu bằng một tên có dấu gạch chéo và một dấu ngoặc nhọn mở @msg This shortcut file is in a form VectorCraft can't read. Tệp phím tắt này có dạng mà VectorCraft không đọc được. @msg not an EMF or WMF file không phải tệp EMF hoặc WMF +@msg not an HP-GL file không phải tệp HP-GL @msg not found không tìm thấy @msg not in pattern editing mode không ở chế độ chỉnh sửa họa tiết @msg finish editing the pattern first hãy hoàn tất chỉnh sửa họa tiết trước @@ -5015,6 +5028,14 @@ theme Appearance Giao diện @msg a symbol can't hold an instance of itself ký hiệu không thể chứa đối tượng của chính nó @msg select an envelope hãy chọn một Envelope @msg select an image hãy chọn ảnh +@msg `background` must be edges, corners or colors `background` phải là edges, corners hoặc colors +@msg `match` must be distance or channels `match` phải là distance hoặc channels +@msg `{key}` must be from 0 to 100 `{key}` phải nằm trong khoảng 0 đến 100 +@msg `colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue `colors` phải liệt kê từ 1 đến 16 màu, mỗi màu viết dạng hex (#rrggbb hoặc #rgb) hoặc là black, white, red, green hay blue +@msg the linked image shows its preview: embed or relink it first hình ảnh được liên kết đang hiển thị bản xem trước: hãy nhúng hoặc liên kết lại trước +@msg a CMYK image has no transparency to set ảnh CMYK không có độ trong suốt để đặt +@msg every pixel matches the background: no image would be left mọi điểm ảnh đều khớp với nền: sẽ không còn lại ảnh nào +@msg an image of {w} × {h} pixels is too large to change ảnh {w} × {h} điểm ảnh quá lớn nên không thể thay đổi @msg select an Image Trace object hãy chọn đối tượng vector hóa ảnh @msg select an object in perspective hãy chọn đối tượng trong phối cảnh @msg select artwork (or a colour group) to recolor hãy chọn tác phẩm (hoặc nhóm màu) để tô lại diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index eea8526b9..05c6539bf 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -645,6 +645,15 @@ antiAlias Sharp 锐利 Created: 创建时间: Crop 裁剪 Crop Image 裁剪图像 + Image Tools 图像工具 + Remove Background… 移除背景… + Remove Background 移除背景 + Background 背景 + Edge Color 边缘颜色 + Picked Colors 选取的颜色 + Corner Color 转角颜色 + Contiguous 连续 + Each Channel 每个通道 Edit Path 编辑路径 Mask 蒙版 Crop Marks 裁切标记 @@ -3399,6 +3408,8 @@ digits Malayalam 马拉雅拉姆数字 Scale at Center 从中心缩放 Up/Down: 上/下: Left/Right: 左/右: + Tate-chu-yoko Up/Down 纵中横上/下 + Tate-chu-yoko Left/Right 纵中横左/右 Temperature 色温 Temperature/Tint… 色温/色调… Template 模板 @@ -4350,6 +4361,7 @@ imageTrace Outline 轮廓 @msg the image's pixels don't match its size 图像的像素与其尺寸不符 @msg {bpp}-bit bitmaps can't be read 无法读取 {bpp} 位位图 @msg not an EMF or WMF file 不是 EMF 或 WMF 文件 +@msg not an HP-GL file 不是 HP-GL 文件 @msg its layers use {_1}, which aren't read yet 其图层使用了 {_1},尚不能读取 @msg the EMF header is damaged EMF 文件头已损坏 @msg damaged 已损坏 @@ -4366,6 +4378,7 @@ imageTrace Outline 轮廓 @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller 画板 {n} 的页面为 {size},超过 PDF 的页面上限 14,400 pt (200 in):它以 UserUnit {unit} 写入,因此忽略 UserUnit 的应用会将其显示为缩小相应倍数 @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in 画板 {n} 的页面为 {size},超过 PDF 的页面上限 14,400 pt (200 in):{label} 页面不带 UserUnit,以点为单位写入,遵守上限的应用会将页面裁切为 200 in @msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5 没有 UserUnit,而长于 14,400 pt (200 in) 的页面需要它来保持尺寸:文件将以 PDF 1.6 写入 +@msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 PDF 1.4 没有 UserUnit,而长于 14,400 pt (200 in) 的页面需要它来保持在 PDF/A-2 的 14,400 单位页面上限之内:文件将以 PDF 1.6 写入 @msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units 画板 {n} 的页面为 {size},对 PDF 3 至 14,400 单位的页面边长而言过长过窄:以 UserUnit {unit} 写入,使短边保持 3 单位以上,遵守上限的应用会把长边裁到 14,400 单位 @msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard {label} 文件的实数最大为 {max},而画板 {n} 的页面长 {side} pt,需要更大的数:请选择不带 PDF/X 标准的 PDF 1.6 或更高版本,或使用更小的画板 @msg page {0} does not exist (the PDF has {1}) 页面 {0} 不存在 (该 PDF 有 {1} 页) @@ -4554,6 +4567,14 @@ imageTrace Outline 轮廓 @msg no such view 没有此视图 @msg printing isn't available here 此处无法打印 @msg select an image 请选择一个图像 +@msg `background` must be edges, corners or colors `background` 必须为 edges、corners 或 colors +@msg `match` must be distance or channels `match` 必须为 distance 或 channels +@msg `{key}` must be from 0 to 100 `{key}` 必须介于 0 到 100 之间 +@msg `colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue `colors` 必须列出 1 到 16 种颜色,每种写为十六进制(#rrggbb 或 #rgb)或 black、white、red、green、blue +@msg the linked image shows its preview: embed or relink it first 链接图像显示的是预览:请先嵌入或重新链接 +@msg a CMYK image has no transparency to set CMYK 图像没有可设置的透明度 +@msg every pixel matches the background: no image would be left 每个像素都与背景匹配:不会留下任何图像 +@msg an image of {w} × {h} pixels is too large to change {w} × {h} 像素的图像过大,无法更改 @msg the document has no artboards 文档没有画板 @msg tool (a tool id) is required 需要 tool (工具 id) @msg unknown tool `{t}` 未知工具 `{t}` diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 60cc2d1d4..cb8a61362 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -662,6 +662,15 @@ font-technology Variable 可變 Created: 建立時間: Crop 裁切 Crop Image 裁切影像 + Image Tools 影像工具 + Remove Background… 移除背景… + Remove Background 移除背景 + Background 背景 + Edge Color 邊緣顏色 + Picked Colors 選取的顏色 + Corner Color 轉角顏色 + Contiguous 連續 + Each Channel 每個通道 Edit Path 編輯路徑 Mask 遮色片 Crop Marks 裁切標記 @@ -2164,6 +2173,7 @@ file-filter Templates 範本 @msg the image's pixels don't match its size 影像的像素與其尺寸不符 @msg {bpp}-bit bitmaps can't be read 無法讀取 {bpp} 位元點陣圖 @msg not an EMF or WMF file 不是 EMF 或 WMF 檔案 +@msg not an HP-GL file 不是 HP-GL 檔案 @msg its layers use {_1}, which aren't read yet 其圖層使用了 {_1},目前尚無法讀取 @msg the EMF header is damaged EMF 檔頭已損毀 @msg damaged 已損毀 @@ -2180,6 +2190,7 @@ file-filter Templates 範本 @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller 工作區域 {n} 的頁面為 {size},超過 PDF 的頁面上限 14,400 pt (200 in):它以 UserUnit {unit} 寫入,因此忽略 UserUnit 的應用程式會將其顯示為縮小相應倍數 @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in 工作區域 {n} 的頁面為 {size},超過 PDF 的頁面上限 14,400 pt (200 in):{label} 頁面不含 UserUnit,以點為單位寫入,遵守上限的應用程式會將頁面裁切為 200 in @msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5 沒有 UserUnit,而長於 14,400 pt (200 in) 的頁面需要它來保持尺寸:檔案將以 PDF 1.6 寫入 +@msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 PDF 1.4 沒有 UserUnit,而長於 14,400 pt (200 in) 的頁面需要它來保持在 PDF/A-2 的 14,400 單位頁面上限之內:檔案將以 PDF 1.6 寫入 @msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units 工作區域 {n} 的頁面為 {size},對 PDF 3 至 14,400 單位的頁面邊長而言過長過窄:以 UserUnit {unit} 寫入,使短邊保持 3 單位以上,遵守上限的應用程式會把長邊裁到 14,400 單位 @msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard {label} 檔案的實數最大為 {max},而工作區域 {n} 的頁面長 {side} pt,需要更大的數:請選擇不含 PDF/X 標準的 PDF 1.6 或更新版本,或使用較小的工作區域 @msg page {0} does not exist (the PDF has {1}) 頁面 {0} 不存在 (此 PDF 有 {1} 頁) @@ -2367,6 +2378,14 @@ file-filter Templates 範本 @msg no such view 沒有這個檢視 @msg printing isn't available here 此處無法列印 @msg select an image 請選取影像 +@msg `background` must be edges, corners or colors `background` 必須為 edges、corners 或 colors +@msg `match` must be distance or channels `match` 必須為 distance 或 channels +@msg `{key}` must be from 0 to 100 `{key}` 必須介於 0 到 100 之間 +@msg `colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue `colors` 必須列出 1 到 16 種顏色,每種寫為十六進位(#rrggbb 或 #rgb)或 black、white、red、green、blue +@msg the linked image shows its preview: embed or relink it first 連結影像顯示的是預覽:請先嵌入或重新連結 +@msg a CMYK image has no transparency to set CMYK 影像沒有可設定的透明度 +@msg every pixel matches the background: no image would be left 每個像素都與背景相符:不會留下任何影像 +@msg an image of {w} × {h} pixels is too large to change {w} × {h} 像素的影像過大,無法變更 @msg the document has no artboards 文件沒有工作區域 @msg tool (a tool id) is required 需要 tool (工具 id) @msg unknown tool `{t}` 未知的工具 `{t}` @@ -4521,6 +4540,8 @@ digits Malayalam 馬來亞拉姆數字 Scale at Center 從中心縮放 Up/Down: 上/下: Left/Right: 左/右: + Tate-chu-yoko Up/Down 縱中橫上/下 + Tate-chu-yoko Left/Right 縱中橫左/右 Temperature 色溫 Temperature/Tint… 色溫/色調… Template 範本 diff --git a/crates/ui-egui/src/panels/align.rs b/crates/ui-egui/src/panels/align.rs index e1076145d..82005595f 100644 --- a/crates/ui-egui/src/panels/align.rs +++ b/crates/ui-egui/src/panels/align.rs @@ -233,11 +233,106 @@ fn align_to(app: &VectorcraftApp) -> AlignTo { if has_key(app) { AlignTo::Key } else { app.ui.align_to } } +/// The Align To choices of the Align panel and of the Control bar's Align To menu, in their order. +const ALIGN_TO: [AlignTo; 3] = [AlignTo::Selection, AlignTo::Key, AlignTo::Artboard]; + +/// The icon and the translated name of Align To choice `v`. +fn align_to_icon_name(v: AlignTo) -> (&'static str, &'static str) { + match v { + AlignTo::Selection => ("dc-alignto-selection", tl!("Align to Selection")), + AlignTo::Key => ("dc-alignto-key", tl!("Align to Key Object")), + AlignTo::Artboard => ("dc-alignto-artboard", tl!("Align to Artboard")), + } +} + +/// The Align To choice that the Align panel and the Control bar show chosen: Artboard while one +/// object is selected (the buttons then align it to the artboard), else [`align_to`]. +fn shown_align_to(app: &VectorcraftApp) -> AlignTo { + aligns_to(align_to(app), objects_aligned(app)) +} + +/// Whether Align To choice `v` can be chosen. Align to Key Object needs a key object or a key +/// guide, or something to make one of: two or more selected objects, or a guide selected with +/// objects. +fn can_choose(app: &VectorcraftApp, v: AlignTo) -> bool { + v != AlignTo::Key || selection_len(app) >= 2 || has_key(app) || guide_for_key(app).is_some() +} + +/// Chooses Align To `v`, as the Align panel's Align To buttons and the Control bar's Align To menu +/// do. Align to Key Object makes the topmost selected object the key object, unless there is one +/// already; with one object, a guide selected with it becomes the key. The stored choice stays as +/// it was, and Align To shows it again once the key is cleared. Align to Selection and Align to +/// Artboard store the choice and let go of the key object. +pub(crate) fn choose_align_to(app: &mut VectorcraftApp, v: AlignTo) { + if !can_choose(app, v) { + return; + } + if v == AlignTo::Key { + if !has_key(app) { + match (topmost(app).filter(|_| selection_len(app) >= 2), guide_for_key(app)) { + (Some(id), _) => app.run("select.key", json!({"id": id.0})).ok(), + (None, Some(g)) => app.run("select.key", json!({"guide": g})).ok(), + (None, None) => None, + }; + } + } else { + app.ui.align_to = v; + if has_key(app) { + app.run("select.key", json!({})).ok(); + } + } +} + +/// The width of the Control bar's Align To menu ([`align_to_menu`]) for buttons `size` points +/// square: the icon of the choice and the arrow after it. +pub(crate) fn align_to_menu_width(size: f32) -> f32 { + size + ALIGN_TO_ARROW +} + +/// The width of the arrow of the Control bar's Align To menu. +const ALIGN_TO_ARROW: f32 = 14.0; + +/// The Control bar's Align To menu, next to its Align buttons: the icon of the choice that the +/// Align panel's Align To shows ([`shown_align_to`]) and an arrow, `size` points tall. Its menu +/// lists Align to Selection, Align to Key Object and Align to Artboard with that choice checked, +/// and a pick chooses it as the Align panel does ([`choose_align_to`]). Align to Key Object is off +/// while no key object can be made. Screen readers get a combo box named "Align To:" whose value +/// is the choice. +pub(crate) fn align_to_menu(app: &mut VectorcraftApp, ui: &mut Ui, size: f32) { + let t = crate::theme::Tokens::get(ui.ctx()); + let shown = shown_align_to(app); + let (icon, name) = align_to_icon_name(shown); + let (rect, resp) = ui.allocate_exact_size(egui::vec2(align_to_menu_width(size), size), egui::Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(rect, egui::CornerRadius::same(5), t.hover); + } + let square = egui::Rect::from_min_size(rect.min, egui::vec2(size, size)); + crate::icons::paint(ui, icon, square.shrink((size * 0.2).round()), t.icon); + let arrow = egui::Rect::from_center_size(egui::pos2(square.right() + ALIGN_TO_ARROW / 2.0, rect.center().y), egui::vec2(10.0, 10.0)); + crate::icons::paint(ui, "chevron-down", arrow, t.text_dim); + resp.widget_info(|| egui::WidgetInfo { + current_text_value: Some(name.to_owned()), + ..egui::WidgetInfo::labeled(egui::Role::ComboBox, ui.is_enabled(), tl!("Align To:")) + }); + let resp = resp.on_hover_text(name); + let picked = egui::Popup::menu(&resp).show(|ui| { + let mut picked = None; + for v in ALIGN_TO { + if menu_item(ui, align_to_icon_name(v).1, can_choose(app, v), shown == v) { + picked = Some(v); + } + } + picked + }); + if let Some(v) = picked.and_then(|r| r.inner) { + choose_align_to(app, v); + } +} + pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let n = selection_len(app); let guides = selected_guides(app); let can_align = n + guides >= 1; - let to = align_to(app); // `spacing` is the Distribute Spacing value. The Distribute Objects and Distribute Spacing // buttons send it while there is a key object or a key guide (#2143). let spacing = app.ui.align_spacing; @@ -299,36 +394,12 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ui.vertical(|ui| { widgets::subheader(ui, tl!("Align To:")); // With one object selected the buttons align it to the artboard, and Align To shows Artboard. - let shown = aligns_to(to, objects_aligned(app)); + let shown = shown_align_to(app); ui.horizontal(|ui| { - for (v, icon, tip) in [ - (AlignTo::Selection, "dc-alignto-selection", tl!("Align to Selection")), - (AlignTo::Key, "dc-alignto-key", tl!("Align to Key Object")), - (AlignTo::Artboard, "dc-alignto-artboard", tl!("Align to Artboard")), - ] { - // A key object is one of two or more selected objects, or a guide selected - // with objects. - let on = v != AlignTo::Key || n >= 2 || has_key(app) || guide_for_key(app).is_some(); - if widgets::icon_button_enabled(ui, icon, tl!(tip), shown == v, on, 28.0).clicked() { - if v == AlignTo::Key { - // The topmost selected object becomes the key object, unless - // there is one already. The Align To choice stays as it was, and - // Align To shows it again once the key is cleared. - // With one object, a guide selected with it becomes the key. - if !has_key(app) { - match (topmost(app).filter(|_| n >= 2), guide_for_key(app)) { - (Some(id), _) => app.run("select.key", json!({"id": id.0})).ok(), - (None, Some(g)) => app.run("select.key", json!({"guide": g})).ok(), - (None, None) => None, - }; - } - } else { - app.ui.align_to = v; - // Aligning to the selection or the artboard lets go of the key object. - if has_key(app) { - app.run("select.key", json!({})).ok(); - } - } + for v in ALIGN_TO { + let (icon, tip) = align_to_icon_name(v); + if widgets::icon_button_enabled(ui, icon, tip, shown == v, can_choose(app, v), 28.0).clicked() { + choose_align_to(app, v); } } }); diff --git a/crates/ui-egui/src/panels/paragraph/tests.rs b/crates/ui-egui/src/panels/paragraph/tests.rs index 39e642075..347975f68 100644 --- a/crates/ui-egui/src/panels/paragraph/tests.rs +++ b/crates/ui-egui/src/panels/paragraph/tests.rs @@ -730,10 +730,11 @@ fn the_character_and_paragraph_groups_hold_the_type_controls() { } /// #5372: at any width the Control bar trims itself from its right end, ending inside its margin. -/// With type selected the inline X/Y/W/H fields go first, then the Distribute and Align buttons, -/// area type's vertical alignment, the Paragraph buttons and last the Paragraph: link, while the -/// Transform link keeps its room; a wider bar never leaves out what a narrower one showed. For one point -/// type object, area type (its vertical alignment) and two objects (the Distribute buttons). +/// With type selected the inline X/Y/W/H fields go first, then the Distribute buttons, the Align To +/// menu and the Align buttons, area type's vertical alignment, the Paragraph buttons and last the +/// Paragraph: link, while the Transform link keeps its room; a wider bar never leaves out what a +/// narrower one showed. For one point type object, area type (its vertical alignment) and two +/// objects (the Distribute buttons). #[test] fn the_control_bar_trims_its_paragraph_section_after_the_controls_to_its_right() { let ctx = control_bar_ctx(); @@ -748,12 +749,13 @@ fn the_control_bar_trims_its_paragraph_section_after_the_controls_to_its_right() groups.push(("vertical alignment", VERTICAL.to_vec())); } groups.push(("Align", vec!["Horizontal Align Left", "Vertical Align Bottom"])); + groups.push(("Align To", vec!["Align To:"])); if case == "two objects" { groups.push(("Distribute", vec!["Vertical Distribute Top", "Horizontal Distribute Right"])); } groups.push(("X/Y/W/H", vec!["X:", "H:"])); let mut seen = vec![false; groups.len()]; - for width in (600..=2400).step_by(10) { + for width in (600..=2600).step_by(10) { let nodes = control_bar_nodes(&ctx, &mut app, width as f32, vec![]); let inside = |x: f64| x <= f64::from(width) - 10.0; let shown: Vec = groups diff --git a/crates/ui-egui/src/shortcut_editor.rs b/crates/ui-egui/src/shortcut_editor.rs index b17a4c47a..7ada99ee5 100644 --- a/crates/ui-egui/src/shortcut_editor.rs +++ b/crates/ui-egui/src/shortcut_editor.rs @@ -374,12 +374,48 @@ pub(crate) struct Snapshot { /// a live-effect or filter plug-in installed later has its row, and one removed has none. pub(crate) fn snapshot() -> Arc { static LAST: Mutex>> = Mutex::new(None); - let revision = menus::plugin_revision(); + let mut revision = menus::plugin_revision(); let last = LAST.lock().unwrap_or_else(|e| e.into_inner()).clone(); - if let Some(s) = last.filter(|s| s.revision == revision) { + // A test's walk hook runs even when a snapshot of this revision is already stored. + #[cfg(test)] + let hooked = { + let f = tests::WHILE_WALKING.take(); + let hooked = f.is_some(); + tests::WHILE_WALKING.set(f); + hooked + }; + #[cfg(not(test))] + let hooked = false; + if let Some(s) = last.filter(|s| s.revision == revision && !hooked) { return s; } + // When a plug-in is installed or removed during the walk, the entries may not match + // `revision`, and the keymap cached for it would lack rows or hold extra ones. The menus are + // walked again at the new revision, and a snapshot that still doesn't match is not cached. + let mut s = build_snapshot(revision); + for _ in 0..8 { + let now = menus::plugin_revision(); + if now == revision { + let mut last = LAST.lock().unwrap_or_else(|e| e.into_inner()); + // A newer snapshot another thread stored meanwhile stays. + if last.as_ref().is_none_or(|l| l.revision <= revision) { + *last = Some(s.clone()); + } + return s; + } + revision = now; + s = build_snapshot(revision); + } + s +} + +/// The snapshot of the menus as they are now, under plug-in revision `revision`. +fn build_snapshot(revision: u64) -> Arc { // Built without the lock held: the Effect menu is walked anew. + #[cfg(test)] + if let Some(f) = tests::WHILE_WALKING.take() { + f(); + } let effects: Arc<[EffectItem]> = build_effect_items().into(); let filters: Arc<[FilterItem]> = build_filter_items().into(); let (head, tail) = fixed_entries(); @@ -396,13 +432,7 @@ pub(crate) fn snapshot() -> Arc { })) .chain(tail.iter().cloned()) .collect(); - let s = Arc::new(Snapshot { revision, effects, filters, entries }); - let mut last = LAST.lock().unwrap_or_else(|e| e.into_inner()); - // A newer snapshot another thread stored meanwhile stays. - if last.as_ref().is_none_or(|l| l.revision <= revision) { - *last = Some(s.clone()); - } - s + Arc::new(Snapshot { revision, effects, filters, entries }) } /// Every tool, command and panel the editor can give a shortcut; [`listed`] says which it lists. @@ -1496,6 +1526,39 @@ mod tests { panic!("the installed plug-ins kept changing"); } + thread_local! { + /// Run by [`snapshot`] on this thread once, after it reads the plug-in revision and before + /// it walks the menus. + pub(super) static WHILE_WALKING: std::cell::Cell>> = const { std::cell::Cell::new(None) }; + } + + /// A plug-in installed while the menus are walked doesn't leave a snapshot under the revision + /// read before the walk: the keymap built for that revision would then lack the plug-in's row. + #[test] + fn a_plugin_installed_while_the_menus_are_walked_gets_a_new_snapshot() { + use vectorcraft_plugins::wat as tpl; + let install = |id: &str| { + let manifest = format!(r#"{{"id":"{id}","name":"{id}","version":"1","kind":"filter","params":{{}}}}"#); + vectorcraft_plugins::registry::install_bytes(&wat::parse_str(tpl::returning(&manifest, r#"{"objects":[]}"#)).unwrap()).unwrap(); + }; + let (before, during) = ("test.ui.walk-before", "test.ui.walk-during"); + install(before); + // Stores the snapshot of this revision, as another test thread may already have done. + snapshot(); + WHILE_WALKING.set(Some(Box::new(move || { + // Another caller builds the snapshot and keymap of the revision read before the walk. + crate::keymap::template_for(&snapshot()); + install(during); + }))); + let s = snapshot(); + let keys: Vec<&str> = s.entries.iter().map(|e| e.key.as_str()).collect(); + let catalog = crate::keymap::template_for(&s).catalog().len(); + vectorcraft_plugins::registry::remove(before); + vectorcraft_plugins::registry::remove(during); + assert!(keys.contains(&"plugin:test.ui.walk-during"), "the walk lists the plug-in"); + assert_eq!(catalog, s.entries.len(), "the keymap of the snapshot's revision has every entry"); + } + #[test] fn format_parse_round_trip_for_every_default() { for e in entries().iter() { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 2c955d453..fa682c027 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -291,6 +291,7 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors, | PostScript `.ps` | open, place | yes (the EPS reader reads its first page; `.epsf` files open and place as EPS) | via Print to PostScript | `tests_epsimport.rs` | | SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests`; text on a path follows basic shapes as their SVG 2 equivalent paths (#1527); CSS transforms and the `font` shorthand (#1565), percentage lengths in symbols (#1596), Inkscape flowed text as area type (#1568) and Inkscape pages as artboards (#1601) import; an unsupported filter is named in its warning (#1598); an artboard exported on its own (Export As with Use Artboards, Export for Screens, Asset Export) has the artboard's name as ``, Save the document's (#3544); attributes in other applications' XML namespaces (`plotter:pen="2"`) kept on their objects from import to export, as `data-*` attributes are (`crates/svg/tests/it/foreign_attributes.rs`); simple shapes export as `<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polygon>` and `<polyline>` where they draw the same as the path (#4652, `crates/svg/tests/it/basic_shapes.rs`); the regions of exported masks and filters (opacity masks, inverted masks, knockout groups) cover the art they apply to, and over type keep a region that covers any artwork (#6401, `crates/svg/tests/it/blend_export.rs`) | | DXF | open, export | yes | yes (R12–2018) | R13–2018 closed SPLINE flags and control-point closure on import verified (#1299); circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE while non-circular curves keep the existing fallback (`crates/cad/src/tests.rs`); [Autodesk group 70](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-DXF/files/GUID-E1F884F8-AA90-4864-A215-3182D47A9C74.htm). DXF 2000–2018 LAYOUT objects write the paper image origin as 148/149 (was a stray 158), the shade plot codes (2004+) and the model type flag, and the plot style placeholder carries no subclass marker (#3085; structure test `r2000_and_later_files_have_the_structure_cad_apps_require`, checked with ezdxf's audit; not yet confirmed in Autodesk Fusion). CNC preset and VCarve QA remain open | +| HP-GL/2 `.plt`, `.hpgl`, `.hgl`, `.hpg` | — | yes (pen moves, pens and widths, line types and attributes, circles, arcs, wedges and Bézier curves, filled and edged rectangles, wedges and polygons, encoded polylines, scaling and rotation; labels, symbol mode, raster images and hatching aren't drawn) | no | recognized by its first instructions after any printer escapes, under its own extensions or one no other format reads; `crates/cad/src/hpgl/tests.rs`, `tests_hpgl.rs`, `import_fuzz.rs` | | DWG | open, export | no | no | no open spec; DXF instead | | EMF / WMF | open, export | yes | yes | an oversized embedded PNG or JPEG is refused from its header, before any pixels are decoded (#1776) | | PSD | open, place, export | yes (open: layers as layers, or the merged image with `layers: false`; place: merged image; every mode and depth; `.psd` and `.pdd`) | yes (layered, max editability) | placing as layers and a PSD import options dialog missing; the embedded ICC profile's tags are 4-byte aligned (#1584) | diff --git a/docs/gaps.md b/docs/gaps.md index da57837a6..a228181ab 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (G5: built-in Energy Beams brush library; estimate 5–15 → 4–14 h; previously minor: G5: built-in Skill Icons symbol library; estimate 6–16 → 5–15 h; previously minor: ruler double-click adds a guide at the pointer, #5239; G5: built-in Status Effects graphic style library; estimate 7–17 → 6–16 h; MCP docs catch-up for UI Frames and Map Markers; previously minor: G5: built-in Inventory Icons symbol library; estimate 8–18 → 7–17 h; previously minor: G9: `.ai` save names a variable font instance's design-space setting, #6388; previously minor: G5: built-in Game Trails brush library; estimate 9–19 → 8–18 h; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: G5: built-in Game HUD graphic style library; estimate 10–20 → 9–19 h; previously minor: G5: built-in Map Markers symbol library; estimate 11–21 → 10–20 h; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (#6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: G5: built-in Energy Beams brush library; estimate 5–15 → 4–14 h; previously minor: G5: built-in Skill Icons symbol library; estimate 6–16 → 5–15 h; previously minor: ruler double-click adds a guide at the pointer, #5239; G5: built-in Status Effects graphic style library; estimate 7–17 → 6–16 h; MCP docs catch-up for UI Frames and Map Markers; previously minor: G5: built-in Inventory Icons symbol library; estimate 8–18 → 7–17 h; previously minor: G9: `.ai` save names a variable font instance's design-space setting, #6388; previously minor: G5: built-in Game Trails brush library; estimate 9–19 → 8–18 h; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: G5: built-in Game HUD graphic style library; estimate 10–20 → 9–19 h; previously minor: G5: built-in Map Markers symbol library; estimate 11–21 → 10–20 h; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -166,6 +166,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi - **Improved:** Eyedropper has an original pipette cursor at its sampling tip; Use Precise Cursors keeps the crosshair. Bitmap/DPI and preference checks cover the distinction (#1269). OS cursor display and Caps Lock remain unverified. +- **Done (#6404):** Eyedropper character copying compensates for source and target text transforms, including highlighted ranges and reverse sampling; invalid dimensions reject the pick before document, default-paint or sample changes. G1 scores and estimates unchanged. - **Done (#1251):** Eyedropper source reads are independent of locks and isolation, including Shift/Shift+Alt and gradient stops. Source and selection preservation, unchanged Alt reverse/selection restrictions, hidden art and Undo/Redo have regression coverage. This addresses the reported behavior, with no new full-parity measurement. - **Done (#1253):** adding paint to an unpainted zero-width stroke restores the existing 1 pt default, including new-art defaults and Swap Fill/Stroke. The real Eyedropper inheritance sequence, targeted appearance items, preserved widths and Undo/Redo have regression coverage. - **Done:** a busy cursor while a file opens off the UI thread or a labelled background save or export runs (`Cursor::Busy` in `crates/tools/src/cursors.toml`). Heavy synchronous effect previews remain open. @@ -855,6 +856,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | Eyedropper copies effective character dimensions between transformed text objects (#6404); scores and estimates unchanged. | | 2026-11-11 | minor | G5: built-in Energy Beams brush library (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail); Missing trimmed; estimate 5–15 → 4–14 h; MCP Brush libraries list Energy Beams. | | 2026-11-11 | minor | G5: built-in Skill Icons symbol library (Attack, Defend, Heal, Buff, Ult); Missing trimmed; estimate 6–16 → 5–15 h; MCP Symbol libraries list Skill Icons. | | 2026-11-10 | minor | G6: MCP catalogue (#6460): headless lists `UI_COMMANDS` (`ui: true`), eight prompts, effect vs command ids, 33 tools, commands/reference; estimate unchanged. | diff --git a/docs/mcp.md b/docs/mcp.md index da260b682..7f6df7983 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -147,6 +147,12 @@ the suite) keep the agent to the folders you give it: others that take `library`), whose replies show it too. `swatch.library.copyToUser` refuses a library whose file is outside the read roots without naming the file, and it and the library saves with `user: true` leave out `path` when the user library folder is outside the read roots. +- `links.list`, `links.check`, `links.info` and the `missingLinks`, `modifiedLinks` and `updatedLinks` rows of + `document.open` leave out a linked file's path when the file is outside the read roots (a relative path is taken + from the document's folder): `path`, `found`, and in `links.info` also `link` and `location`, with the `fileSize` + and `modified` the document keeps of that file. Each row keeps its id, `name` (`fileName`), status and other + fields. This applies to every document, also one the person or a client with wider roots opened. The paths a + client gives `links.relink` and `links.unembed` stay in their replies, and the document's links are not changed. - Each flag may be given more than once (#3546): a path inside any of the read roots may be read, one inside any of the write roots written, so one session can read a project folder and a shared assets folder, or write to an output folder and a scratch folder, without widening a root to their common parent. A folder given twice @@ -502,7 +508,7 @@ applied colors still join the list while it is hidden. | `invoke_menu` | `{command, params?}` | Invokes a menu item by command id. UI commands such as `view.*` and `window.*` are in `command_list` with `ui: true`; running them needs the desktop app (`--connect`). | | `open_panel` | `{panel}` | Remote only. `panel` is a panel id (`layers`, `swatches`, `colorGuide`, …, as `window.panel` takes) or its display label (`"Color Guide"`), in any case. | | `screenshot` | `{path?, scale?, artboard?, window?, max_side?, traceViews?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard with the pixels of a PNG `export` with `background: "white"` at the same scale; `traceViews:true` draws Image Trace objects in the view set with `imageTrace.setView`, as in the app's window; `window:true` captures the app window (remote only), as `ui_screenshot` does, and `max_side` (1–4096, window captures only) scales the returned image down as described for `ui_screenshot`. With `path` the backend (the headless session or the app) writes the PNG there too; a window capture written there keeps its full size with `max_side`. | -| `open_file` | `{path, importer?, params?}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`/`.epsf`, `.ps`, `.dxf`, `.emf`, `.wmf`, PNG (`.png`/`.pns`), JPEG, GIF, WebP, TIFF, BMP (`.bmp`/`.dib`/`.rle`), Targa (`.tga`/`.vda`/`.icb`/`.vst`), PCX, Pixar (`.pxr`), JPEG 2000 (`.jpf`/`.jpx`/`.jp2`/`.j2k`/`.j2c`/`.jpc`), HEIC/HEIF (`.heic`/`.heif`), JPEG XL (`.jxl`), PSD (`.psd`/`.pdd`)/PSB (an image opens as a document of its pixel size; a PSD or PSB file with its layers as layers, or as its merged image through `run_command document.open` with `layers: false`; a JPEG XL file as its first frame). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). A file of an extension only an installed importer plug-in lists opens with that importer ([Plug-ins](#plug-ins)); `importer: "plugin.<id>"` opens the file with the importer it names, which must list the file's extension, also one a built-in format reads (in the app, as File › Open With does), and `params` gives that importer's parameters (their defaults when omitted). `run_command document.formats` lists the formats. | +| `open_file` | `{path, importer?, params?}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`/`.epsf`, `.ps`, `.dxf`, `.emf`, `.wmf`, HP-GL (`.plt`/`.hpgl`/`.hgl`/`.hpg`, and a file of an extension no other format reads that starts with plotter instructions), PNG (`.png`/`.pns`), JPEG, GIF, WebP, TIFF, BMP (`.bmp`/`.dib`/`.rle`), Targa (`.tga`/`.vda`/`.icb`/`.vst`), PCX, Pixar (`.pxr`), JPEG 2000 (`.jpf`/`.jpx`/`.jp2`/`.j2k`/`.j2c`/`.jpc`), HEIC/HEIF (`.heic`/`.heif`), JPEG XL (`.jxl`), PSD (`.psd`/`.pdd`)/PSB (an image opens as a document of its pixel size; a PSD or PSB file with its layers as layers, or as its merged image through `run_command document.open` with `layers: false`; a JPEG XL file as its first frame). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). A file of an extension only an installed importer plug-in lists opens with that importer ([Plug-ins](#plug-ins)); `importer: "plugin.<id>"` opens the file with the importer it names, which must list the file's extension, also one a built-in format reads (in the app, as File › Open With does), and `params` gives that importer's parameters (their defaults when omitted). `run_command document.formats` lists the formats. | | `save_file` | `{path?, compress?, version?, preview?, precision?, options?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). For a native file, `compress: true` gzips it (default: the `useCompression` preference, Preferences › File Handling › Use Compression), `version` (1 to 4) writes it for an older VectorCraft (1 and 2 are never compressed, and `version: 2` with `compress: true` is an error), `preview: true` embeds a PNG of the first artboard, and `precision` (0 to 9) rounds path anchors and handles to that many decimal places for that save only (colors, transforms and type stay exact). `options` passes the format's save options as `run_command document.formats` lists them. When `compress`, `version`, `preview` or `precision` is given both at the top level and in `options` with different values, the call is an error (`` `compress: true` and `options.compress: false` disagree: give the option once ``) and nothing is written. A key in `options` that no save format takes is an error, and nothing is written (see Parameter keys). The save drops each option the written format doesn't read. The reply's `warnings` then name each such option given (for example `` `compress` was not used: SVG files don't read it `` for an `.svg` path, and `` `options.preview` was not used: … `` for a key in `options`; a `.ai` file reads `compress` but not `version`, `preview` or `precision`). For a `.ai` file, these warnings name the kind of `.ai` written: a legacy .ai (version 7) for `aiVersion: "7"`, a non-PDF-compatible .ai for `pdfCompatible: false`, and a PDF-compatible .ai otherwise, including a save that reuses the options the document was last saved with. `run_command document.save` and the other save commands refuse any option given beside the path and in `options` with different values, and give the same warnings for a top-level `compress`, `version`, `preview` or `precision` and for each key in `options` that the format doesn't read. The headless session and the app receive the same `app.save` params, and the app is also sent `wait: true`: the call returns once the file is written, or with the error (such as a path outside the write root), even with Background Save on. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the Background Save preference. | | `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `svgz`, `pdf`, `ai` (the PDF-compatible `.ai` that Save writes, which carries the document when every artboard is written in order), `eps`, `dxf`, `emf`, `wmf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text), `vectorcraft` or `template` (a native template). The tool's `format` enum and `document.formats` list them, generated from the engine's format table. When `format` is omitted, it comes from the path's extension. PDF and `.ai` write one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based). SVG and SVGZ write one file per chosen artboard. EPS writes one file of the visible art; with `artboard`, `range`, `options.artboards` or `options: {"useArtboards": true}` it writes one file per chosen artboard, after a central file of the visible art when there are two or more, and `useArtboards: false` keeps the one file. Rasters, DXF, EMF and WMF write one artboard, or one file per chosen artboard with `options: {"useArtboards": true}`. Rasters also take `options: {"rect": [x, y, width, height]}` (page points, y down): that area of the page at the given `ppi`/`scale`, without an artboard and without changing the document; it is refused with `artboard`, `artboards`, `range` or `useArtboards`, with a non-positive or non-finite size, and for non-raster formats. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds (the reply adds their `bounds`, and reports `format` and the encoder's `warnings` as a whole-document export does); `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. | | `add_text` | `{text, x?, y?, width?, height?, fit?, fitMinPercent?, path?, mode?, pathEffect?, size?, font?, color?, justify?, vertical?}` | Point type at (x, y) (`justify`: `left`, `center` or `right` starts, centres or ends each line on x); area type with `width`/`height` (`fit`: `none`, `autoHeight`, `shrinkText`, as `text.areaOptions`); or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`; SVG export writes type on a path with any effect other than `rainbow` as outlines, with a warning); `justify` (also `auto`, `justifyAll`) aligns the paragraphs of every kind, as `text.setStyle` does later. `vertical: true` uses the existing vertical writing mode for every kind (default `false`, horizontal). Returns `{id}`, with `overflow: true` when the text doesn't fit its frame or path. | @@ -886,14 +892,18 @@ export. PDF/X-1a and PDF/X-3 files are PDF 1.3 files too. A page with a side longer than 14,400 pt is written with a `UserUnit` (PDF 1.6): its longer side divided by 14,400, rounded up to the next thousandth, with the page's sizes and coordinates in those units, so that no side is longer than 14,400 units (ISO 32000-1, Annex C.2). A PDF 1.5 file with such a page is written as PDF 1.6, with a warning, and -keeps PDF 1.5's encryption (128-bit RC4). PDF/X files and PDF 1.3 and 1.4 files write such a page in points, and apps +keeps PDF 1.5's encryption (128-bit RC4). A PDF/A-2b file at PDF 1.4 with such a page is written as PDF 1.6 too, with a +warning, whether or not `longPagesInPoints` is on, so that its page boundaries stay within PDF/A-2's limit of 14,400 units +unless the page is too narrow for that (below) +([veraPDF rule 6.1.13-11](https://github.com/veraPDF/veraPDF-validation-profiles/wiki/PDFA-Parts-2-and-3-rules#rule-6113-11); +PDF/A-2 files may declare PDF 1.0 to 1.7, [rule 6.1.2-1](https://github.com/veraPDF/veraPDF-validation-profiles/wiki/PDFA-Parts-2-and-3-rules#rule-612-1)). +PDF/X files and other PDF 1.3 and 1.4 files write such a page in points, and apps that keep to the limit cut it to 200 in. `longPagesInPoints: true` (off by default; "Write pages longer than 200 in at full size in points" in the Save PDF dialog's General section) writes such a page in points at every PDF version, without a `UserUnit`, and a PDF 1.5 file stays PDF 1.5; the warning then says the page is written in points. The option applies -only to files without a standard: PDF/X files write such a page in points anyway, and PDF/A files at PDF 1.5 and later keep -the `UserUnit`, which keeps their page boundaries within PDF/A-2's limit of 14,400 units (ISO 19005-2, 6.1.13). A PDF/A -file at PDF 1.4 writes such a page in points, as other PDF 1.4 files do, so its page boundaries are longer than that -limit. The dialog disables the +only to files without a standard: PDF/X files write such a page in points anyway, and PDF/A files keep the `UserUnit`, which +keeps their page boundaries within PDF/A-2's limit of 14,400 units (ISO 19005-2, 6.1.13), a PDF/A file at PDF 1.4 +included (written as PDF 1.6). The dialog disables the checkbox when a standard is chosen. In PDF 1.3 and 1.4 files, PDF/X-1a and PDF/X-3 among them, a real number can be at most 32,767, and whole numbers are written as integers: a page longer than 32,767 pt whose sides aren't whole numbers of points fails the export with an error naming the page before the thumbnails are drawn and the transparency is @@ -1759,7 +1769,9 @@ as it is. A string as the first argument is used as the text. objects as the text of an expression that makes the same value, such as `({"a":[1, "x"]})`. A function, a getter, a date, a regular expression, an error, a host object such as `vc`, an array with properties besides its elements, an object whose prototype is not `Object.prototype`, a property that is not enumerable, or an object that contains -itself throws a `TypeError`. A value deeper than 64 levels, with more than 1,048,576 entries or with text longer than 16,777,216 +itself throws a `TypeError`. So does a property that is not writable or not configurable, an object or array after +`Object.preventExtensions`, `Object.seal` or `Object.freeze`, and an array whose `length` is read-only, since the text +would make each of them writable, configurable and extensible again (VectorCraft's own rule). A value deeper than 64 levels, with more than 1,048,576 entries or with text longer than 16,777,216 characters throws a `RangeError`. The run is one transaction. When the script ends, each document it changed gets one undo step named Run Script. A @@ -1922,6 +1934,8 @@ script reads files with them: objects for the same path are different objects. - `name`, `displayName`, `fsName`, `absoluteURI`, `fullName`, `relativeURI`, `path`, `parent` and `toString()` come from the path. `exists`, `length`, `created`, `modified`, `hidden`, `readonly` and `alias` read the file system. + `exists` is true for a `File` only when the path names something other than a folder, and for a `Folder` only + when it names a folder. `File.encode`, `File.decode` and `File.isEncodingAvailable` (and the same on `Folder`), `File.fs`, and the folders `Folder.temp`, `Folder.desktop`, `Folder.myDocuments`, `Folder.userData` and the others are there too. - `open('r')` opens a file to read, and `read([chars])`, `readln()`, `readch()`, `seek(pos[, mode])`, `tell()`, `eof` @@ -5912,10 +5926,12 @@ reduced size"; the document keeps the whole file) or, when it can't be read that allowance has bytes, drawn as a crossed gray box on the canvas and left out of exports ("too large to draw"); `document.open` and `file.place` say the same in their `warnings`). `links.info {id?}` is the Link Info: `image.info`'s fields plus `status`, `format`, the file's `ppi` and the `effectivePpi`, `scale` (% of 100%), `rotation` (degrees, counter-clockwise), `placement` and, for a linked file, `fileName`, `location`, `fileSize`, `created`, `modified`. +With automation read roots, a file outside them has no `path` or `found` in `links.list` and `links.check`, and no +`link`, `path`, `location`, `fileSize` or `modified` in `links.info`; its row keeps the file's name. `links.goTo {id}` selects an image; `links.embed {ids?}` keeps linked files' pixels in the document (an image whose file is missing stays linked: relink it first); `links.unembed {id, path}` writes an embedded image to a file (another extension converts it) and links to it, and without `path` returns `{name, dataBase64}`. -`links.placementOptions {ids?, preserve?: transforms|bounds|fileDimensions|fit|fill, align?: topLeft…bottomRight, +`links.placementOptions {ids?, preserve?: transforms|bounds|fileDimensions|fit|fill, align?: topLeft…bottomRight, clip?}` decides how a relinked or updated file takes an image's place (default `bounds`: stretched into the old bounds; `clip` puts it in a clip group of the old bounds when it is larger). The UI commands `links.editOriginal` and `links.reveal` open the linked file in its app or show it in its folder (desktop). Edit Original runs only from the app's window, @@ -6256,6 +6272,33 @@ clipboard service offers it (the Windows desktop service doesn't read metafiles {"name":"run_command","arguments":{"command":"file.place","params":{"path":"/tmp/chart.emf","at":[300,200]}}} ``` +## HP-GL import + +`document.open` and `file.place` read HP-GL/2 plot files (`.plt`, `.hpgl`, `.hgl`, `.hpg`) into one layer, with the +artboard round the art. A file whose extension no other format reads opens as HP-GL when it starts with plotter +instructions. Older HP-GL files are read by the HP-GL/2 rules for the instructions the two share. The reader follows +the [HP-GL/2 and HP RTL Reference Guide](https://support.hp.com/sg-en/document/c01121263). + +Plotter units (1016 to the inch, y up) become points through the scaling the file sets (`IP`, `IR`, `SC`) and its +rotation (`RO`). Each line drawn with the pen down is one stroked path in the pen's color (`SP`, `PC`, `NP`, `CR`) +and width (`PW`, `WU`; 0.35 mm by default), with the line type (`LT`, `UL`) as a dash pattern and the line attributes +(`LA`) as caps and joins. Circles, arcs, wedges and Bézier curves (`CI`, `AA`, `AR`, `AT`, `RT`, `EW`, `WG`, `BZ`, +`BR`) are drawn as curves. Filled rectangles, wedges and polygons (`RA`, `RR`, `WG`, `PM` with `FP`) become filled +paths, and edged ones (`EA`, `ER`, `EW`, `EP`) become stroked paths. Encoded polylines (`PE`) are read, and printer +escape sequences and PJL lines around the instructions are skipped. + +Two rules are VectorCraft's own: before the first `SP` the pen is pen 1, and the hard-clip limits are 11 × 8.5 in +from the origin, so P2 lies there when the file sets no scaling points. +`SP0` draws nothing. Labels, the symbols of symbol mode (`SM`) and raster images are not drawn, the input window +(`IW`) does not clip the art, the art of later pages is drawn over the first page, and hatched and patterned fills are +filled solid; `warnings` lists each of these. A file named as a plot file that doesn't start with plotter +instructions is refused with "not an HP-GL file". VectorCraft does not write HP-GL. + +```json +{"name":"open_file","arguments":{"path":"/tmp/part.plt"}} +{"name":"run_command","arguments":{"command":"file.place","params":{"path":"/tmp/part.plt","at":[300,200]}}} +``` + ## TIFF, BMP and Targa export `document.export` (and `export`) writes them like the other raster formats: one artboard (or `useArtboards`, one @@ -7684,13 +7727,14 @@ Character Style Options uses `charStyle.edit {name, to, attrs}` to replace the n attribute map in one undo step. `to` is trimmed and must be unique. The command updates text using the style while preserving local overrides; Undo/Redo restores the definition and its users. The `charStyleOptions` dialog exposes `name`, `font_family`, `font_style`, `size`, `leading`, `kerning`, `tracking`, `case`, -`position`, `underline`, `strikethrough`, `charAlign`, `h_scale`, `v_scale`, `baseline_shift`, `rotation`, -`language`, `fill`, `fill_tint`, `stroke`, `stroke_tint`, `stroke_width`, `overprint_fill`, `overprint_stroke`, +`position`, `underline`, `strikethrough`, `charAlign`, `h_scale`, `v_scale`, `baseline_shift`, `tcyUpDown`, +`tcyLeftRight`, `rotation`, `language`, `fill`, `fill_tint`, `stroke`, `stroke_tint`, `stroke_width`, `overprint_fill`, `overprint_stroke`, `ot_figure`, `ot_position`, `ot_liga`, `ot_calt`, `ot_dlig`, `ot_swsh`, `ot_salt`, `ot_titl`, `ot_ordn`, `ot_frac`, `ot_sets`, `section` (`general`, `basic`, `advanced`, `color` or `opentype`), and `preview`. An empty field leaves its attribute unset. `leading` and `kerning` also accept `Auto` to store an explicit automatic value. Advanced Character Formats holds `h_scale` and `v_scale` (percent, 1 to 10000), -`baseline_shift` (points, -1296 to 1296), `rotation` (degrees, stored from -180 to 180) and `language` (a name or tag from Document +`baseline_shift` (points, -1296 to 1296), the Tate-chu-yoko Settings `tcyUpDown` and `tcyLeftRight` (points, +-1296 to 1296, as `text.setRangeStyle` takes them; an explicit 0 stays distinct from unset), `rotation` (degrees, stored from -180 to 180) and `language` (a name or tag from Document Setup's languages; English: USA stores the explicit default `null`, and a tag not in the list that the style already has is kept). Language sets the language the characters are shaped in; it does not change hyphenation or spelling. @@ -7720,7 +7764,7 @@ captured attributes. Without selected text, it starts with the character default The New Paragraph Style dialog has the sections of Paragraph Style Options, filled in from the selected paragraph's attributes, or without selected text from the paragraph defaults for the interface language, and OK sends the attributes of its sections as `attrs`. Reset Panel in Basic Character -Formats removes those fields from the definition, Reset Panel in Advanced Character Formats removes its five, Reset +Formats removes those fields from the definition, Reset Panel in Advanced Character Formats removes its seven, Reset Panel in Character Color removes its five attributes and Reset Panel in OpenType Features removes `features`. `ui.charStyleOptions.resetPanel {}` performs that reset for the section selected in either character-style dialog; it is disabled on General. @@ -7944,7 +7988,9 @@ pendingItem}`; a command does not turn Safe Mode on or off. `plugin.setEnabled { off for the next launch, as Help › Safe Mode's Enable on Re-launch does; it does not load, unload or uninstall anything now, and `plugin.install` of a disabled plug-in turns it on. Turning a plug-in on by `id` or by installing it also turns on the file it is stored in, which names it when it stopped a launch while loading; when the new version can't be saved -over that file, the plug-in stays disabled. `plugin.list` reports `safeMode` and `disabled`, and +over that file, the plug-in stays disabled. When the new version is saved but its grant can't be, and the file can't be +returned to what it held before (the old module, or no file), the plug-in is turned on, and the next launch skips it +until it is approved. `plugin.list` reports `safeMode` and `disabled`, and `plugin.reload` skips disabled plug-ins. A change to the disabled list is saved on top of the list the file holds at that moment, so a change another window saved to the same preferences meanwhile is kept. When the list file can't be read (it is too large) or holds another format or version, it is left as it is; a file that isn't JSON is replaced. @@ -9337,6 +9383,54 @@ there is no step. → `{symbols: [name…], ids: [instance id…], skipped: [{la {"name":"run_command","arguments":{"command":"addon.document.symbolsFromLayers","params":{"replace":true}}} ``` +## Image Tools add-on + +Image Tools is a built-in add-on for placed images: its command is `addon.image.removeBackground`, and in the desktop +app it lives in Object › Plug-ins › Image Tools. It is a VectorCraft addition. + +### Remove Background + +`addon.image.removeBackground` makes the background of the selected images, or of the images in `ids`, transparent by +lowering the alpha of their own pixels. The work runs in the app, and no service or model is used. + +- With `background: "edges"` (the default), the background is the most common color along the image's edges. When the + edges hold at least as many clear pixels as pixels of that color, as after an earlier run, nothing is cleared. +- With `background: "corners"`, the background is the average color of the image's four corner pixels. A clear corner + is left out of the average, and when all four are clear, nothing is cleared. +- With `background: "colors"`, the background is the colors in `colors`. `colors` lists 1 to 16 colors, as an array or + as one string split by commas or spaces. Each is written as other color params take a hex color (`#rrggbb` or `#rgb`, + with or without the `#`) or named black, white, red, green or blue. +- With `contiguous: true` (the default), the background is cleared only where it reaches in from an edge, so the same + color inside the subject stays. With `contiguous: false`, it is cleared wherever it is. +- `tolerance` (0 to 100, default 10) is how near a pixel's color must be to a background color to be cleared, as a + percentage. With `match: "distance"` (the default), it is a percentage of the RGB distance from black to white. With + `match: "channels"`, it is a percentage of 255 steps on the one of red, green and blue that differs most, so a + tolerance of `t / 255 × 100` clears the colors within `t` 8-bit steps on every channel. `softness` (0 to 100, + default 10) is the band past the tolerance over which pixels fade from cleared to kept. These ranges and defaults + are VectorCraft's own. +- A value outside 0 to 100, a `background` other than `edges`, `corners` or `colors`, a `match` other than `distance` + or `channels`, a `contiguous` that is not true or false, or a `colors` that lists no color, more than 16, or one that + is not a color or has an alpha part is refused, and the document stays as it was. + +The result stays an image, embedded as a PNG with an alpha channel and the source's color profile and resolution. An +image of 16-bit samples keeps 16 bits, a grayscale image stays gray, and a linked image is embedded. All the images +change in one undo step, and an image that `ids` names twice changes once. An image whose pixels do not change is left +as it is, and when none changes the command adds no undo step and returns `changed: 0`. `cleared` counts the pixels +made fully clear in the images that changed. The command refuses, and changes nothing, when an image is CMYK, a linked +image shows its preview (embed or relink it first), an image would need more than the decode allowance while it is +worked on (2 GiB, or 512 MiB in the web app, counted from the file's header before it is decoded), or every pixel of an +image would be cleared. Remove Background… opens the command's dialog with Preview off; with Preview on, the canvas +shows the result for the values in the dialog, and Cancel puts the image back. While a dialog previews the command, +the session keeps the decoded pixels of the images it changes (at most 128 MiB, or 32 MiB in the web app), so the next +preview step does not decode them again; it releases them when the dialog closes with OK or Cancel. A run that is not +a preview step does not keep them. +→ `{changed, ids, cleared}` + +```json +{"name":"run_command","arguments":{"command":"addon.image.removeBackground","params":{"background":"colors","colors":["#ffffff","#f0f0f0"],"tolerance":5,"softness":15}}} +{"name":"run_command","arguments":{"command":"addon.image.removeBackground","params":{"background":"corners","contiguous":false,"match":"channels","tolerance":12.55,"softness":0}}} +``` + ## Type Tools add-on Type Tools is a built-in add-on for type: its commands are `addon.type.textTweaker` and `addon.type.releasePath`, and diff --git a/docs/plugins.md b/docs/plugins.md index b1015a1d4..c737e08f3 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -245,7 +245,9 @@ capability model proposed for PhotoCraft plug-ins records the new hash. When the new module or its grant can't be written to the `Plug-ins` folder, the folder keeps the old module and its grant, and the old module loads at the next start. If the write that puts the old module back fails as well, the folder holds the new module with the old grant, the grant does not cover that module, and the - plug-in is skipped at the next start until it is approved again. A grant whose hash is not 64 lower-case + plug-in is skipped at the next start until it is approved again. When the folder held no module for that id, that + write removes the new module; when the removal fails, the folder holds the new module and no grant covers it. In + both cases a disabled plug-in is turned on, as for any install the folder holds. A grant whose hash is not 64 lower-case hexadecimal digits covers nothing. `plugin.remove` forgets the grant, and so does a successful `plugin.install` of a module that lists no permissions under that id, so the earlier bytes need a new approval. When `plugin.remove` can't remove the grant from `grants.json`, it returns an error, the plug-in stays installed, and a second `plugin.remove` diff --git a/docs/releasing.md b/docs/releasing.md index 9de6abb3b..bfa3f4e21 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -170,7 +170,7 @@ to the target triple, so host build scripts aren't affected. AppStream metainfo, the MIME type for VectorCraft documents, the icons and the licence files. **Document types.** The desktop entry's `MimeType` lists every format File › Open reads but PSD, -PSB, `.ps` PostScript, Targa, PCX, Pixar, JPEG 2000 and HEIC/HEIF, so file managers list VectorCraft under Open With for them. +PSB, `.ps` PostScript, Targa, PCX, Pixar, JPEG 2000, HEIC/HEIF and HP-GL, so file managers list VectorCraft under Open With for them. `ai.storyteller.vectorcraft.mime.xml` declares the types that shared-mime-info lacks: VectorCraft documents and templates, `.ait` templates and Affinity files (`application/x-affinity`). An `.af` file gets the Affinity type by its content, and `.afdesign` and `.afpub` files by their extension. diff --git a/docs/roadmap.md b/docs/roadmap.md index 1b5574061..41066cbb4 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (M10/G5: Energy Beams brush library; MCP docs list Energy Beams; previously minor: M10/G5: Skill Icons symbol library; MCP docs list Skill Icons; previously minor: M10/G5: Status Effects graphic style library; MCP docs catch-up for symbol libraries; previously minor: M10/G5: Inventory Icons built-in symbol library; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: M10/G5: Game HUD graphic style library; previously minor: resizable icon-strip flyouts and bottom-aligned panel footers, #5713; previously minor: M10/G5: Map Markers built-in symbol library; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (#6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: M10/G5: Energy Beams brush library; MCP docs list Energy Beams; previously minor: M10/G5: Skill Icons symbol library; MCP docs list Skill Icons; previously minor: M10/G5: Status Effects graphic style library; MCP docs catch-up for symbol libraries; previously minor: M10/G5: Inventory Icons built-in symbol library; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: M10/G5: Game HUD graphic style library; previously minor: resizable icon-strip flyouts and bottom-aligned panel footers, #5713; previously minor: M10/G5: Map Markers built-in symbol library; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). > @@ -142,7 +142,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | M0 | Skeleton + vertical slice | ✅ done | — | | M1 | Selection, transform, layers, MCP (common command tools, strict keys, session resources and version-aware result hints) | ✅ mostly done (document-tab artwork drag with lossless resource transfer and one target Undo; malformed MCP app replies discard the connection without replay, #1761; bounded control socket waits and connection-slot recovery, #1726; bounded synthetic control input, #1709; no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending; the Transform fields take one Tab each, the W/H link after them) | 4–6 | | M2 | Drawing tools + smart guides | ✅ mostly done (#3380 deletion creates independent selected path pieces and keeps bounds-fitted gradients in place; endpoint highlighting remains open; Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending); a shape just drawn moves by its centre point and turns from the zone outside a corner with its tool still active (#5477); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537), including the existing Snap to Glyph and Alignment Guides toggles (#6164); no View submenu; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123) | 12–18 | -| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit; finite-arithmetic follow-up regressions and full CI passed ([Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area))), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls | 4–8 | +| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper copies transformed character dimensions (#6404) and styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit; finite-arithmetic follow-up regressions and full CI passed ([Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area))), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls | 4–8 | | M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | PDF authored ActualText preserved once in editable import (#6165; first-glyph line/style, bounded input; estimates unchanged); PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. `.ai`/EPS type from the text document keeps its paragraphs' tab stops (#6147); `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them (#6137); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts | 3–6 (existing scope; Affinity export unestimated) | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); opaque solid-fill knockout paths draw once in hard-edge/supersampled raster export, with analytical-AA compositing unchanged; file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); Craquelure/Mosaic Tiles/Stained Glass distance searches optimized with pixel-invariance checks and the `texture_drag_perf` probe (#1257); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (live compound shapes: Make, Alt-click a Shape Mode, member modes, Release, Expand (#4522); single-result Pathfinder names and SVG IDs retained (#1293); Divide open-cutting-path geometry, appearance and history verified (#1307; existing segment cap remains); Simplify preview/counts and rewritten-path component selection verified (#1272; the slider opens at the Auto-Simplify level; a Direct Selection part of a path simplifies on its own); Compound appearance and member fill-rule edits verified (#1197); Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open); Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog | 3–6 | @@ -190,6 +190,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | Eyedropper copies effective character dimensions between transformed text objects (#6404); scores and estimates unchanged. | | 2026-11-11 | minor | M10/G5: Energy Beams built-in brush library; MCP docs catch-up; estimates unchanged | | 2026-11-11 | minor | M10/G5: Skill Icons built-in symbol library; MCP docs catch-up; estimates unchanged | | 2026-11-10 | minor | M10/G5: Status Effects built-in graphic style library; MCP docs catch-up for UI Frames and Map Markers; estimates unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 18d50447f..908895ad6 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (#6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). > @@ -217,7 +217,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Selection, transform & align tools | 6 | 85% | Direct Selection segment click/Delete corrected (#1278); deletion splits an ordinary contour into selected sibling objects with bounds-fitted gradients kept in place (#3380, deletion only; `crates/engine/tests/it/delete_path_pieces.rs`); open-path anchor Delete keeps gaps; Select › Start Global Edit finds the paths, compound paths and symbol instances of the selected object's shape (Appearance, Size and artboard options) and gives an edit of one to all of them in proportion, as one undo step (#3521), and on a group the groups like it, an edit inside one reaching the same object in the others (#4939), and a Shift-click on a highlighted match leaves it out; the Align panel menu's Align to Glyph Bounds › Point Text and Area Text measure type by its glyphs' ink for Align and Distribute (#4231); remaining: Free Transform/Scale/Reflect along a rotated box, Global Edit of rotated copies, transform nuances (no-op anchor Align history regression-tested, #1180 follow-up) | 4–6 | estimated; Global Edit regression-tested (`crates/engine/src/tests/globaledit.rs`; Quick Action, menu label and Escape in `panels::properties::tests::start_global_edit_quick_action_and_escape`) | | Drawing tools (Pen, Curvature, Pencil, shapes, Shaper) | 7 | 85% | Shaper construction widget, Pencil/Pen nuances, Blob Brush options, Touch Type, Dimension tool; see [ui-parity.md](ui-parity.md) | 8–13 | estimated; Curvature point display regression-tested (#1236, in part); Polygon/Star exact-size count persistence regression-covered; Live Corners' Relative rounding done (#3091), score unchanged; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123), score and hours unchanged | | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 80% | Shape Builder on the reporter's file (#937: art past 4,096 segments is cut by lines again; unconfirmed on the file itself); gap detection shares Live Paint’s finder, with a 72 pt custom cap and the existing options; Live Paint Gap Options | 4–8 | estimated; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog (lowered from 86%: open issues); compound attributes regression-tested (#1197); single-result source names survive SVG export/import (#1293), with [SVG ID semantics](https://www.w3.org/TR/SVG2/struct.html#IDAttribute) and original-shape tests; Simplify component selection and preview regression-tested (#1272); Simplify of a Direct Selection part of a path regression-tested (`tests/simplify_part.rs`); Divide open-cutting-path geometry and appearance regression-tested (#1307); the Shape Builder's edge-source grid is bounded in memory and files a segment once per cell (`planar.rs` test `rect_index_holds_a_bounded_number_of_references`); live compound shapes (Make, Alt-click, member Shape Modes, Release, Expand, saved and exported) regression-tested (#4522) | -| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | 30.0's gradient additions are done (dither, perceptual blending, presets); freeform gradients in SVG/PDF, mixed spot/process gradients in SVG, PDF gradients mixing a spot color with RGB or Lab stops, and dithered spot gradient fills and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, which PDF export writes as images in process colors | 4–7 | estimated; Recolor model selection is available through the engine and MCP (#2520), and the dialog's color sliders (HSB, RGB, CMYK, Lab, Tint for a spot color) edit the selected row's new color in its mode, and the Edit tab's Global Adjust sliders (Saturation, Brightness, Temperature, Luminosity, -100% to 100%) change every recolored color at once (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251); screen-bounded Recolor Edit chip list (#3416), score and hours unchanged | +| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | Eyedropper copies effective character dimensions across text transforms (#6404); 30.0's gradient additions are done (dither, perceptual blending, presets); freeform gradients in SVG/PDF, mixed spot/process gradients in SVG, PDF gradients mixing a spot color with RGB or Lab stops, and dithered spot gradient fills and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, which PDF export writes as images in process colors| 4–7 | estimated; Recolor model selection is available through the engine and MCP (#2520), and the dialog's color sliders (HSB, RGB, CMYK, Lab, Tint for a spot color) edit the selected row's new color in its mode, and the Edit tab's Global Adjust sliders (Saturation, Brightness, Temperature, Luminosity, -100% to 100%) change every recolored color at once (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251); screen-bounded Recolor Edit chip list (#3416), score and hours unchanged | | Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (six generated libraries including Game Trails and Energy Beams, User Defined, Other Library…, Save Brushes); Art brush Overlap and Pattern brush auto corners are done; remaining: pen pressure on macOS, Stylus Wheel, more built-in brush libraries, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) | | Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated; Transform typing, sliders, preview, and undo are covered in `crates/ui-egui/src/panels/tests_appearance.rs` | | 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, the rest of 3D Classic (Revolve, Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | @@ -349,7 +349,7 @@ Depth here is "does the basic use work", not the area's full score. | Shape tools (rectangle, ellipse, polygon, star, line) | 9 | 95% | all shape tools, Live Corners | | Selection, move, scale, rotate | 9 | 92% | bounding box, smart guides, transform tools | | Pen and Pencil, basic use | 7 | 88% | [ui-parity.md](ui-parity.md); a few Pencil and Curvature options missing | -| Fill, stroke, colour, swatches | 9 | 95% | M3 done; highlighted-text Eyedropper font/paint and source/target transparency refusal regression-tested; scores and hours unchanged | +| Fill, stroke, colour, swatches | 9 | 95% | M3 done; transform-aware Eyedropper character dimensions (#6404); highlighted-text Eyedropper font/paint and source/target transparency refusal regression-tested; scores and hours unchanged | | Gradients, basic | 4 | 90% | Gradient panel and on-canvas annotator | | Type: point and area text, font, size | 8 | 88% | font menu with previews; Hyphenation… and Justification… options per paragraph (#2310, #2755) | | Align, group, arrange | 6 | 95% | | @@ -632,7 +632,7 @@ since 2026-10-01. - **Character-style Auto leading:** capture and explicit style attributes retain Auto as a reset, distinct from omitted leading. Redefine and later numeric updates preserve local leading overrides; the Character Styles marker recognizes Auto correctly. Regression assertions cover partial definitions, Undo/Redo and native save/load serialization. Public references: [Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html) and [Leading](https://helpx.adobe.com/illustrator/using/line-character-spacing.html). - **Character-style numeric bounds:** `charStyle.new`, `charStyle.edit`, and `charStyle.setAttrs` accept size from 0.1 to 1296 pt, explicit leading from 0.1 to 5000 pt, horizontal and vertical scale from 1% to 10,000%, and baseline shift and Tate-chu-yoko offsets from -1296 to 1296 pt. Out-of-range input leaves the style and Undo history unchanged; Auto leading remains available. These limits match the Character panel controls. - **Character Style Options, General and Basic Character Formats:** the dialog edits a style's name and stored character formats together. Empty fields remain unset, checkboxes show an indeterminate unset state, Basic's Reset Panel clears its explicit settings, and Preview can be canceled without changing history. OK saves one undo step; New Character Style offers the same fields from captured text or the interface language's defaults without a selection. Public reference: [Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). -- **Character Style Options, Advanced Character Formats:** Character Style Options and New Character Style set a style's Horizontal Scale and Vertical Scale (1% to 10,000%), Baseline Shift (-1296 pt to 1296 pt), Character Rotation (stored from -180° to 180°) and Language; text that uses the style takes them and keeps its local overrides. Empty fields remain unset, an explicit 100%, 0° or English: USA stays distinct from unset, and the section's Reset Panel clears only these five. Language sets the language the characters are shaped in, not hyphenation or spelling. Public reference: [Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). +- **Character Style Options, Advanced Character Formats:** Character Style Options and New Character Style set a style's Horizontal Scale and Vertical Scale (1% to 10,000%), Baseline Shift (-1296 pt to 1296 pt), the Tate-chu-yoko Settings Up/Down and Left/Right (-1296 pt to 1296 pt), Character Rotation (stored from -180° to 180°) and Language; text that uses the style takes them and keeps its local overrides. Empty fields remain unset, an explicit 100%, 0 pt, 0° or English: USA stays distinct from unset, and the section's Reset Panel clears only these seven. Language sets the language the characters are shaped in, not hyphenation or spelling. Public references: [Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html) and [Format East Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html). - **Character Style Options, Character Color and OpenType Features:** Character Style Options and New Character Style set a style's fill and stroke (None or a document swatch; a global swatch stays linked, with a Tint from 0% to 100%), stroke Weight, Overprint Fill and Overprint Stroke, and its Figure, Position, Standard and Discretionary Ligatures, Contextual, Stylistic and Titling Alternates, Swash, Ordinals, Fractions and Stylistic Sets 1 to 20; text that uses the style takes them and keeps its local overrides, and editing a global swatch later updates the style and its text. Empty controls remain unset, an explicit None, off, Default Figure or Default Position stays distinct from unset, each stylistic set is on, off or unset, a gradient or pattern paint stays until another paint is chosen, a changed OpenType control keeps the style's other feature tags, and each section's Reset Panel clears only its own attributes. Public references: [Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html) and [Format text with OpenType features](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/format-text-with-opentype-features.html). - **Opening style options from the style panels:** a double-click on a user character style's row opens its Character Style Options (`ui.charStyleOptions`), as the panel menu's item does; a paragraph style's row opens its Paragraph Style Options (`ui.paraStyleOptions`), as the panel menu's item does. A single click still applies the style, the second click of the double-click does not apply it again, and the built-in Normal style opens no dialog. Test `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options`. - **Character Style Options window size:** the dialog keeps a fixed width wide enough for the widest tab's controls and scrolls its sections inside a window shorter than them, so it stays on screen and its OK and Cancel buttons stay visible; the width is the same in every section and the height follows the section. Test `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. @@ -691,7 +691,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Artboard deletion keys:** Delete/Backspace delete selected boards in one Undo step, retaining the original first board in the current order when all are selected, as in the public [Delete artboards guide](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/delete-artboards.html). The last board and active drags stay protected; neither key clears selected artwork. Headless shortcut regressions in `tests/distortkeys.rs` cover reordered boards, artwork, saved state and Undo/Redo. - **Locked artboards:** a click in the Artboards panel's lock column locks or unlocks an artboard in one undo step (`artboard.setProps {index, locked}`), and the native format saves the lock. The Artboard tool doesn't move, copy, resize or delete a locked artboard, Rearrange and Fit to Artwork Bounds leave it as it is, and its name isn't edited on the canvas. The art on it (each object whose bounds overlap it, a group as one object) acts as locked art until it is unlocked: clicks, marquees, `select.set`, `select.all` and the tools pass over it, and commands that act on the selection leave it; art drawn or pasted onto it stays selected and editable until the selection leaves it out. Tests in `crates/doc/src/lib.rs`, `tests/panelcmds.rs`, `xform/artboard.rs`, `panels/artboards.rs` and `canvas.rs` cover them. Public references: [Lock artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/lock-artboards.html) and [Managing Artboards in Illustrator](https://creativepro.com/managing-artboards-in-illustrator/). - **Linux RPM packaging:** 64-bit runtime capabilities replace Fedora-specific required package names (#1275); glibc 2.35, libm and libgcc_s are declared alongside dynamically loaded xkbcommon and Vulkan/EGL. Generated x86_64/aarch64 metadata and isolated synthetic-provider resolution checked; real-distro install/start-up QA remains pending. -- **Linux file associations:** the desktop entry lists the MIME type of every format File › Open reads but PSD, PSB, `.ps` PostScript, Targa, PCX, Pixar, JPEG 2000, HEIC/HEIF and JPEG XL, `.ai`, `.ait` and EPS included, and the MIME package declares the types shared-mime-info lacks (VectorCraft documents and templates, `.ait`, Affinity), so file managers list VectorCraft under Open With for them, as Finder does on macOS. `the_linux_desktop_entry_opens_every_readable_format` in `apps/vectorcraft/src/tests.rs` checks both files against `OPEN_EXTS`. On Debian 13, Ubuntu 24.04 and Fedora 42, `desktop-file-validate` accepts the desktop entry, `update-mime-database` accepts the MIME package, and `gio` registers the app for every one of these types. +- **Linux file associations:** the desktop entry lists the MIME type of every format File › Open reads but PSD, PSB, `.ps` PostScript, Targa, PCX, Pixar, JPEG 2000, HEIC/HEIF, JPEG XL and HP-GL, `.ai`, `.ait` and EPS included, and the MIME package declares the types shared-mime-info lacks (VectorCraft documents and templates, `.ait`, Affinity), so file managers list VectorCraft under Open With for them, as Finder does on macOS. `the_linux_desktop_entry_opens_every_readable_format` in `apps/vectorcraft/src/tests.rs` checks both files against `OPEN_EXTS`. On Debian 13, Ubuntu 24.04 and Fedora 42, `desktop-file-validate` accepts the desktop entry, `update-mime-database` accepts the MIME package, and `gio` registers the app for every one of these types. - **Architecture:** 19+ crates with enforced layering (`cargo xtask layers`). Every action is a command (~400 engine + ~50 UI). Undo is unlimited via structural sharing. `command.batch` runs several commands as one transaction. - **Parameter schemas:** a command can carry a machine-readable parameter schema (the new `vectorcraft-apischema` crate): each parameter's type, range, unit, whether it is required, its default, whether null counts as left out, and the API levels it was added and deprecated at. `engine.commands` lists the schema in the shape of a JSON Schema 2020-12 object schema, a test keeps the command's `params` text equal to the text the schema writes, and a new call has the params checked against it before the command runs (batch steps before the first step), with an error naming the parameter and no change to the document; saved steps keep a key the schema does not name, and their values are checked. The View commands `view.capsLock`, `view.saved.new`, `view.saved.edit`, `view.saved.list`, `view.transparencyGrid` and `view.snapToGlyph.options` have schemas. The schemas belong to API level 1 (`vectorcraft_engine::schemas::API_VERSION`), and a test fails when a schema changes without a new level. Tests: `crates/apischema/src/tests.rs` (with `conformance.rs`, a separate check of the JSON form against the JSON Schema 2020-12 rules), `schemas::tests` and `cmd::views::tests::each_view_command_refuses_a_bad_value_naming_the_parameter` in `vectorcraft-engine`. @@ -740,6 +740,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Scripts: File › Scripts › Other Script… and `script.run` run a JavaScript file against the open documents as one transaction with one undo step for each changed document. A failed or stopped run puts every document back. `crates/engine/src/tests/script_run.rs`. - Scripts: Window › Script Console shows each run's `$.write` and `$.writeln` output, `vc.log` entries, uncaught error with the script's name, line and column, and a report of its time, host calls and slowest commands, keeping at most 2,048 lines and counting the lines it dropped. `script.run`'s report carries the same data, and `script.console`, `script.console.clear` and `ui.copyScriptConsole` read, clear and copy it. `crates/engine/src/tests/script_console.rs`. - Scripts: the `$` object has the other members the Dollar object page lists (https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/), from `$.about()` to `$.version`, and scripts have the global `localize(object, ...values)` (https://extendscript.docsforadobe.dev/extendscript-tools-features/localizing-extendscript-strings/), `toSource(value)` and `Object.prototype.toSource()`. Each run starts with the interface's locale, and what a script changes in `$` lasts only for that run. `crates/engine/src/tests/script_dollar.rs`, `crates/js/src/extension_tests.rs`. + - Scripts: `toSource` throws a `TypeError` for a property that is not writable or not configurable, a sealed, frozen or non-extensible object or array, and an array with a read-only `length` (VectorCraft's own rule). `crates/js/src/extension_tests.rs`. - Scripts: the Script Console panel's report line reads as `script.console` gives it, `time unavailable` included on a platform without a clock; counts of host calls, dropped lines and cut lines take the singular for one, in each language's own plural forms; and a run that is no longer available still gets its end line (`crates/engine/src/tests/script_console.rs`, `crates/ui-egui/src/panels/tests_script_console.rs`). - Scripts: an error `$.evalFile` throws for a file it cannot run is reported at that file's line and column only while no `catch` has taken it. Caught and thrown again later, in the script or in another file, it is reported where it was thrown again, and a new error whose message holds its text is reported where that error was thrown. VectorCraft's own rule. `crates/engine/src/tests/script_include.rs`, `crates/js/src/host_tests.rs`. - Scripts: an uncaught error that one or more `finally` blocks pass on unchanged, in the script or in a function it called, is reported at the line and column where it was thrown, in `script.run`'s report and the Script Console. An error a `finally` block throws is reported at that throw, and one its `return`, `break` or `continue` discards is not reported. VectorCraft's own rule. `crates/engine/src/tests/script_include.rs`, `crates/js/src/host_tests.rs`. @@ -750,6 +751,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Scripts folder: File › Scripts lists the `.js` and `.jsx` files of the `Scripts` folder next to the preferences, each named after its file without the extension, with each subfolder as a submenu, above Check Script… and Other Script… (https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/install-and-run-scripts.html). VectorCraft's own rules: the folder is read again when the window becomes active and when the File menu opens (in the macOS menu bar, when any of its menus opens, and the new listing shows the next time the menu opens), links are not followed, and one read stops at 2,048 entries, 512 scripts, 128 subfolders, 4 levels or 250 ms. `script.listInstalled` returns the listing. `crates/engine/src/tests/script_folder.rs`, `crates/ui-egui/src/tests/scripts_menu.rs`. - Open Recent Files under `--automation-read-root`: the control channel's `ui.menu.list` and `file.recentFiles` leave out the recent files outside the read roots, the other rows keep their ids, and `file.openRecentN` for one outside them answers `no such recent file`, as an empty slot does. `crates/ui-egui/src/tests/automation.rs`. - `ui.inspect` under `--automation-read-root`: the documents still opening, the recent files, library panels' files and folder preferences (saved, and in an open Preferences dialog) outside the read roots are left out of the reply, a library loaded from outside them shows by its file name in `ui.inspect` and `ui.docking`, and the app's state is not changed. `crates/ui-egui/src/tests/automation.rs` (`ui_inspect_leaves_out_paths_outside_the_read_root`). + - Link replies under `--automation-read-root`: `links.list`, `links.check`, `links.info` and `document.open`'s link rows leave out the path, found path and folder of a linked file outside the read roots, and the stored size and date of it in `links.info`; each row keeps its id, file name and status, and the document's links are not changed. `crates/engine/src/tests/linkspanel.rs` (`link_paths_outside_the_read_roots_are_left_out`), `crates/engine/src/tests/placed_document.rs` (`link_info_leaves_out_a_placed_documents_path_outside_the_read_roots`). - Swatch, brush, symbol and graphic style library lists under `--automation-read-root`: library files and the user library folder outside the read roots are left out, a library loaded from outside them is listed and found by its file name, and the app's own list is not changed. `crates/engine/src/tests/library_roots.rs`. - Scripts: `alert`, `confirm` and `prompt` open dialogs during a run, each with a Stop button that ends the run and puts every document back. In the app the run waits at the call for the person's answer; a run over MCP or the command line takes its answers in order from `script.run`'s `answers`, and the report lists each dialog with its answer. `crates/engine/src/tests/script_dialogs.rs`, `crates/ui-egui/src/tests/script_dialogs.rs`. - Parameter keys: the engine refuses a key that a command's params description doesn't name before the command runs, in every build and for every caller, including the app's own calls, the commands a command runs and the steps of `command.batch` and `action.batch`. An action recorded by an older version still plays from the Actions panel and the Batch dialog (`Session::replay`). `crates/engine/tests/it/param_keys.rs`. @@ -920,6 +922,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A marquee selects an Intertwine group only when it reaches one of the group's members, as for a plain group, and it selects a repeat only when it reaches one of the copies the repeat draws. Direct and Group Selection's marquees select the anchor points of an Intertwine group's members, not the group, and Group Selection's E marquee selects the members inside it. A key object needs another selected object outside it. A Shift-click or any other change that leaves one object selected clears the key, and `select.key` sets none on one object or on a group selected with only its own members. With one object selected, the Align panel, the Control bar and the Properties panel align it to the artboard whatever Align To is set to. With two or more objects selected and no key object, Align to Key Object makes the topmost of them the key, whatever order they were selected in, and the Align panel, the Control bar and the Properties panel align to it. Align To keeps its Selection or Artboard choice and shows it again once the key is cleared. + The Control bar has an Align To menu after its Align buttons. The public documentation offers Align to Selection, Align to Key Object and Align to Artboard in the Control panel (VectorCraft's Control bar) as well as in the Align panel ([Move, align, and distribute objects](https://web.archive.org/web/20250115013546/https://helpx.adobe.com/illustrator/using/moving-aligning-distributing-objects.html)). The menu shows the icon of the choice the Align panel's Align To shows, and a pick sets the same choice as the Align panel's buttons do. Align to Key Object is off while no key object can be made. When the bar runs short of room, it drops the Distribute buttons first, then the Align To menu, then the Align buttons. Object › Align aligns objects as the align buttons do: it aligns one object to the active artboard whatever Align To is set to, and otherwise it aligns to the key object while there is one, else to the Align To choice, which is saved with the preferences (`ui.inspect` → `ui.align_to`). With direct-selected anchor points, Object › Align aligns the points to their own bounds. While one object is selected, Align To shows Artboard. Edit › Keyboard Shortcuts lists each of the Align panel's Align Objects, Distribute Objects and Distribute Spacing buttons under Align, with no default shortcut, as the reference app's [release notes](https://web.archive.org/web/20261006170350/https://helpx.adobe.com/illustrator/desktop/new-features/release-notes.html) describe for its Align and Distribute commands. A shortcut set there runs what a click on the button runs, with the Align To choice, the key object and the Distribute Spacing value (`align.horizontalLeft` and the other `align.*` UI commands). The command palette lists `select.key` as Cancel Key Object, and a pick cancels the key object (#541). @@ -948,7 +951,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Curvature point markers are circular, with no direction handles. While drawing, its own point feedback replaces generic selection markers; existing paths use the selection overlay, including partial selections and Hide Edges. Anchor Size applies, and Direct Selection and Pen retain their handles. The real egui-frame regressions in `crates/ui-egui/src/canvas.rs` verify marker positions, counts and unchanged documents (#1236, in part); the existing-path interpolation request remains open. The curve through the points (`curvature_spline` in `crates/tools/src/draw2.rs`) makes each stretch of three points follow the circle through them: a smooth point's handles lie along that circle's tangent, each `chord / (3 cos²(α/2))` long (α between handle and chord, capped at 90°), and the ends of an open path mirror their neighbour's tangent in their chord, so a J or a U curves evenly, a turn tighter than 30° comes to a point, and uneven spacing doesn't loop; the rubber band, a dragged or inserted point and `path.convertAnchor` to smooth use the same rule, while the Pencil's fitting keeps its own; `tests/draw2.rs` checks that the rubber band is the path the click makes (#1236, in part). The Smooth tool smooths only the stretch of path under the drag (#1547, fixes #1512). While Alt is held during a Paintbrush stroke or before a press with no path selected that the Smooth tool can act on, the Paintbrush shows a closing cursor (a crosshair with a ring), and so does the Pencil with Alt key toggles to Smooth Tool off when Alt is pressed after the drag starts; releasing the mouse button with Alt still held closes the path. Before a Paintbrush press with such a path selected, Alt shows the Smooth cursor, and holding Alt at the press smooths that path with the Smooth tool's radius and Smooth Tool Options' Fidelity. A Pencil drag started with Shift or Alt held draws a straight line instead, which stays open. With Shift held at the press, the Pencil draws a straight line from the press point to the pointer at 45° steps from the Constrain Angle, as [Draw straight lines with the Pencil tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-straight-lines-with-the-pencil-tool.html) describes; pressed on an end of a selected open path, the line continues the path from that end (part of #3799). With Option/Alt held at the press and Alt key toggles to Smooth Tool off, the Pencil draws a straight line at any angle, whose ends snap to Smart Guides, and it stays open when Alt is still held at the release; Shift and Alt together keep the 45° steps (#3799). While Shift or Alt is held before the press, the Pencil shows a straight-line cursor (a crosshair with a short line), and over an end of a selected open path, where the line continues the path, a path-continuation cursor (the same line leaving a small square), as the page describes. - - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). A click with a grid tool asks for the size, the divider counts and the grid options (#1377, in part): a skew for each set of dividers, Use Outside Rectangle As Frame or Create Compound Path From Ellipses, and Fill Grid; they are parameters of `shape.rectangularGrid` and `shape.polarGrid`, stay as the tool's options and apply to drags too. A shape just drawn stays adjustable with its tool: its Live Shape widgets drag, a press on its centre point (Show Center) moves the selection with the Selection tool's move arrow, snapping and Alt copy, and a press in the rotate zone just outside a bounding-box corner turns it about the centre with the Selection tool's rotate cursor (#5477; tests `a_shape_tool_moves_the_selection_by_its_centre_point_and_turns_it_from_a_corner` and, through the engine, `shapetools.rs`). The curve a skew percentage follows and the direction of the radial skew are our own (the public documentation gives neither), and the dialogs have no reference point locator. An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812). With the bounding box showing, the Selection tool shows a selected ellipse's two pie widgets just outside it; dragging one sets its angle in one undo step and double-clicking one makes the ellipse whole again. A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812). + - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). A click with a grid tool asks for the size, the divider counts and the grid options (#1377, in part): a skew for each set of dividers, Use Outside Rectangle As Frame or Create Compound Path From Ellipses, and Fill Grid; they are parameters of `shape.rectangularGrid` and `shape.polarGrid`, stay as the tool's options and apply to drags too. A shape just drawn stays adjustable with its tool: its Live Shape widgets drag, a press on its centre point (Show Center) moves the selection with the Selection tool's move arrow, snapping and Alt copy, and a press in the rotate zone just outside a bounding-box corner turns it about the centre with the Selection tool's rotate cursor (#5477; tests `a_shape_tool_moves_the_selection_by_its_centre_point_and_turns_it_from_a_corner` and, through the engine, `shapetools.rs`). A drag on one of its eight bounding-box handles scales it as the Selection tool's handles do, with their cursors and snapping, instead of drawing a new shape from the handle. A shape tool takes a press as a handle's only on the drawn handle square (VectorCraft's own rule: 3 screen points from its centre along each axis at the default Anchor Size, a half point more or less per step; the Selection tool takes 5), so a press just beside a handle still draws (tests `a_shape_tool_scales_the_selection_by_its_bounding_box_handles`, `a_shape_tool_draws_from_a_press_just_beside_a_handle` and `a_shape_tool_scales_the_shape_just_drawn_by_its_bounding_box_handle`). The curve a skew percentage follows and the direction of the radial skew are our own (the public documentation gives neither), and the dialogs have no reference point locator. An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812). With the bounding box showing, the Selection tool shows a selected ellipse's two pie widgets just outside it; dragging one sets its angle in one undo step and double-clicking one makes the ellipse whole again. A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812). - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. The Flare tool's center drag turns the rays by 0.1 radian at each drag update in which the pointer's document y grows, and back by as much at each one in which it falls, Shift held keeps their turn, and Space held during either of its drags moves the flare. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). A click with a grid tool asks for the size, the divider counts and the grid options (#1377, in part): a skew for each set of dividers, Use Outside Rectangle As Frame or Create Compound Path From Ellipses, and Fill Grid; they are parameters of `shape.rectangularGrid` and `shape.polarGrid`, stay as the tool's options and apply to drags too. The curve a skew percentage follows and the direction of the radial skew are our own (the public documentation gives neither), and the dialogs have no reference point locator. An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812). With the bounding box showing, the Selection tool shows a selected ellipse's two pie widgets just outside it; dragging one sets its angle in one undo step and double-clicking one makes the ellipse whole again. A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812). - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. The Flare tool's center drag turns the rays with the pointer, Shift keeps them in their original orientation (unturned, or turned by a nonzero General › Constrain Angle), and Space held during either of its drags moves the flare. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). A click with a grid tool asks for the size, the divider counts and the grid options (#1377, in part): a skew for each set of dividers, Use Outside Rectangle As Frame or Create Compound Path From Ellipses, and Fill Grid; they are parameters of `shape.rectangularGrid` and `shape.polarGrid`, stay as the tool's options and apply to drags too. The curve a skew percentage follows and the direction of the radial skew are our own (the public documentation gives neither), and the dialogs have no reference point locator. An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812). With the bounding box showing, the Selection tool shows a selected ellipse's two pie widgets just outside it; dragging one sets its angle in one undo step and double-clicking one makes the ellipse whole again. A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812). - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. The Flare tool's center drag turns the rays with the pointer, Shift keeps them in their original orientation, and Space held during either of its drags moves the flare. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). A click with a grid tool asks for the size, the divider counts and the grid options (#1377, in part): a skew for each set of dividers, Use Outside Rectangle As Frame or Create Compound Path From Ellipses, and Fill Grid; they are parameters of `shape.rectangularGrid` and `shape.polarGrid`, stay as the tool's options and apply to drags too. The curve a skew percentage follows and the direction of the radial skew are our own (the public documentation gives neither), and the dialogs have no reference point locator. An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812). With the bounding box showing, the Selection tool shows a selected ellipse's two pie widgets just outside it; dragging one sets its angle in one undo step and double-clicking one makes the ellipse whole again. A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812). @@ -1315,7 +1318,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Cmd/Ctrl-click on the Gradient panel's Gradient Fill box or on the Tools panel's Gradient button resets the gradient of the active Fill or Stroke to the linear White, Black gradient, as one undo step ([Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)). With a gradient stop or a freeform gradient point selected, Alt-clicking a color swatch in the Swatches panel recolors it instead of the inactive Fill or Stroke. - **Freeform gradient colors in their own model:** Object ▸ Expand and Object ▸ Create Gradient Mesh (`object.mesh.create`) mix a freeform gradient's points in the color model they share: points of C50 M20 Y0 K10 give mesh points of C50 M20 Y0 K10, and RGB points keep their values in a working RGB space other than sRGB. The canvas, the gradient chips and the SVG, PDF, EPS and EMF writers mix the points in that model too. PDF and EPS write a gradient whose points are all CMYK, or all Gray in a CMYK file, as a CMYK image, which PDF's Output settings convert as they convert any CMYK image. Points in different models mix in display RGB (on the canvas in a CMYK document that shows transparency, as the inks of each point), and a CMYK document stores the result of Expand as CMYK through the color settings. Points colored along CMYK stops (`paint.editGradient {stops}`, or a CMYK gradient switched to freeform) are CMYK, and so are a point `paint.freeform.splitLine` adds between CMYK points and a point `paint.freeform.addPoint` adds without `color` to CMYK points. Public reference: [Gradients](https://web.archive.org/web/20250714042311/https://helpx.adobe.com/illustrator/using/gradients.html) and [Meshes](https://web.archive.org/web/20250812201019/https://helpx.adobe.com/illustrator/using/meshes.html), which do not state how colors in different models mix; the rule is VectorCraft's own. - - **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty-one more arrowheads (among them Line Arrow: two arms of the head's weight (stroke weight × arrowhead scale, at least 0.25 pt: the line's weight at 100 % for lines of 0.25 pt or more) at right angles, cut square, #5421), and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance. + - **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty-one more arrowheads (among them Line Arrow: two arms of the head's weight (stroke weight × arrowhead scale, at least 0.25 pt: the line's weight at 100 % for lines of 0.25 pt or more) at right angles, cut square, #5421), and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance. A width profile's sides run as a smooth C1 curve between the width points (a monotone cubic Hermite, Fritsch–Carlson, with the one-sided three-point PCHIP slope at the profile's ends and flat either side of a step), sampled adaptively along the path (two samples per profile segment, refined where the width bends, and leaning on dense path flattening) so straight runs draw it too; added samples offset along the source curve's exact tangent, not the flattened chord, so high zoom follows the true width; the sampled edges are fitted with G1 cubics within a tenth of the flattening tolerance (0.001 pt at the default 0.01 pt) rather than one cubic per flatten step, so exports stay close to a uniform stroke's path data; a point reshapes the curve as far as its neighbours' neighbours (their slopes depend on it), and coincident points still step. - **Brushes (part of #852):** double-clicking a brush in the Brushes panel (or the panel menu's Brush Options…) opens Calligraphic Brush Options (Name; Angle, Roundness and Size, each Fixed, Random, Pressure, Tilt, Bearing or Rotation with its Variation; live previews of the nib and of a light–heavy–light stroke) or Bristle Brush Options (Shape and the bristle sliders); OK edits the brush and every stroke painted with it. The Paintbrush records the pen pressure along each stroke (kept with the path, saved, and joined when a stroke continues a path); a Calligraphic value on Pressure goes from value − variation at the lightest pressure to value + variation at the heaviest, on the canvas and in SVG, PDF and the other exports. Brushes saved before keep their look. Scatter Brush Options (Size, Spacing, Scatter and Rotation, each Fixed, Random between two values, Pressure from the first at the lightest pressure to the second at the heaviest, Tilt, Bearing or Rotation; Rotation relative to Page or Path), Art Brush Options (Width, Scale Proportionately, Stretch to Fit Stroke Length or Stretch Between Guides with the guides' start and end, the four Directions, Flip Along and Across, Overlap) and Pattern Brush Options (Scale, Spacing, the five tiles shown, Flip Along and Across, Stretch to fit, Add space to fit or Approximate path) each have Colorization (None, Tints, Tints and Shades, Hue Shift with its Key Color picked from the art's colours) and a live stroke preview; values from files and commands are clamped to their ranges. Art Brush Options › Overlap places art on each run between sharp corners so folds do not overlap (`ArtBrush.overlap`; `art_brush_overlap_splits_at_corners`). Still to come: Stylus Wheel, pen pressure on macOS where the windowing layer reports none, and tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web. Under Tints and Tints and Shades, art in black ink alone goes by its ink amount, and a process or gray stroke color's tints and shades stay in that color's model; these are VectorCraft's own rules. Type in a scatter brush's art takes the brush's colorization as its paths do: Tints, Tints and Shades and Hue Shift recolor the characters' solid fills and strokes in each copy the brush draws, and Brush Options' Key Color list, whose first color is Hue Shift's default key, counts the characters' fills and their strokes wider than 0. The Brushes page says that a scatter, art or pattern brush takes its colors from the current stroke color and the brush's colorization method (<https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html>) and does not mention type there; recoloring the characters' paints like the art's fills and strokes is VectorCraft's own rule. The Symbol Stainer mixes the characters' strokes of type toward the stain as it mixes their fills. Evidence: `tests::scatter_colorization_recolors_type`, `tests::art_colors_list_the_most_used_first`, `tests::stain_mixes_type_strokes_too` (vectorcraft-brush). @@ -1464,6 +1467,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur In a `.ai` opened from its editing data, Bas Relief in an art style takes its Detail, Smoothness and Light direction from the style's settings block, and an object other than type with it opens as itself with the live effect. A settings block that VectorCraft can't read (malformed, another effect's, a value out of range or a key it doesn't know) keeps the object's drawn look (part of #637; [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646); `crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`). PDF export and `.ai` saves write a page with a side longer than 14,400 pt with a `UserUnit` (PDF 1.6), its longer side divided by 14,400 and rounded up to the next thousandth, so that no side is longer than 14,400 units (ISO 32000-1, Annex C.2); a PDF 1.5 file with such a page is written as PDF 1.6 with PDF 1.5's encryption, PDF/X files and PDF 1.3 and 1.4 files write it in points (in PDF 1.3 and 1.4, a page past 32,767 pt that needs a real number past 32,767 is refused with an error naming it), a page whose short side is 3 pt or longer but would be under 3 units at that `UserUnit` gets a smaller one that keeps the short side at 3 units, and the export's warnings give each such page's size and how it is written (#6382). The Save PDF dialog's "Write pages longer than 200 in at full size in points" (`longPagesInPoints`, off by default) writes such pages in points without a `UserUnit` at every PDF version, in files without a PDF/X or PDF/A standard. PDF export warns when a PDF/X file has no bleed on a side where the document's bleed has one, and writes the file as asked. + A PDF/A-2b export at PDF 1.4 with a page longer than 14,400 pt is written as PDF 1.6 with a `UserUnit` and a warning, whether or not `longPagesInPoints` is on, so its page boundaries stay within PDF/A-2's limit of 14,400 units (ISO 19005-2, 6.1.13) unless the page is too narrow for that (`crates/pdf/src/tests/marks.rs`). PDF/X-1a, PDF/X-3 and PDF 1.3 files keep the art under the hidden part of a picture, gradient, pattern, mesh or raster effect that a clipping mask or clipping layer crops: flattening rasterizes only the part of such art that its clipping path shows, on its own page and on the next one. Layers the file leaves out are left out of the flattening, and transparent art that a clipping path hides entirely is removed (`crates/engine/src/cmd/fileio/tests_pdfx.rs`). Placed (linked) files in a `.ai`'s editing data come in as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (a file found next to the document, at its path, or in the folder the editing data names relative to the document's, such as `src/`, is read as the document opens, and its link takes that file's size, modification time and hash). A file with them no longer falls back to its PDF part, so its guides, sublayers and hidden objects open (#1264; `crates/eps/src/import/ai/tests.rs`, `crates/engine/src/tests/links.rs`). In a file opened that way, a gradient with stops in the middle runs its whole ramp across its shape and a gradient object no longer passes its fill to the next path (#1390); text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line, the frame made taller with a warning in the open result (#1392). Placed (linked) files in a `.ai`'s editing data come in as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (a file found next to the document or at its path is read as the document opens). A file with them no longer falls back to its PDF part, so its guides, sublayers and hidden objects open (#1264; `crates/eps/src/import/ai/tests.rs`, `crates/engine/src/tests/links.rs`). In a file opened that way, a gradient with stops in the middle runs its whole ramp across its shape and a gradient object no longer passes its fill to the next path (#1390); text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line, the frame made taller with a warning in the open result (#1392). @@ -1489,6 +1493,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Pixar picture files (`.pxr`) open, place and paste, from "The Format of Stored Pictures" in the Pixar Image Computer manuals: any set of the R, G, B and A channels in 8 or 12 bits, dumped or run-length encoded with all four packet kinds and block ends, in tiles of any size (pixels of border tiles past the picture are dropped). R alone is gray, a missing color channel is 0 and a missing A opaque; 12-bit samples are clamped to 0.0 to 1.0; color matted to black becomes straight color; a null tile is transparent when the picture has A and black otherwise. The offsets aren't read, and the image takes the default resolution. Version 2 headers read like version 0. A file cut short, one with a tile whose writing wasn't finished, one with header values or packets the format doesn't define, and one whose pixels as RGBA pass the pixel allowance are refused with a message. Every reader recognizes a Pixar file by its magic number. Tested on synthetic files only (`crates/engine/src/cmd/fileio/tests_pxrread.rs`). - JPEG 2000 JP2 and raw codestream images open and place from `.jpf`, `.jpx`, `.jp2`, `.j2k`, `.j2c` and `.jpc`. Each axis uses the valid display resolution (`resd`), else the valid capture resolution (`resc`), whichever box comes first ([ITU-T T.800](https://www.itu.int/rec/T-REC-T.800/en), I.5.3.7.2); an axis with none uses 72 ppi. Multiple codestreams, JPX composition or layers, and unsupported color spaces are refused with a message. The decoder checks image size before allocating (`crates/engine/src/cmd/fileio/tests_jp2read.rs`). - HEIC and HEIF images open and place from `.heic` and `.heif`, as the [supported file formats](https://helpx.adobe.com/illustrator/kb/supported-file-formats-illustrator.html) list High Efficiency Image under both Open and Place (and [Fix HEIF file import errors on Windows](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-other-file-types/fix-heif-file-import-errors-on-windows.html) covers both ways of bringing HEIF files in): HEVC-coded single pictures and grids of tiles, 8 to 10 bits, with an alpha image, decoded by heic-rs in the engine's default `heif` feature. The container's rotation, mirror and crop are applied. Without an `nclx` box, the HEVC stream's video signal type gives the samples' matrix and range. An RGB ICC profile, or Display P3 with the sRGB curve named by an `nclx` box or, without a color box, by the stream, is converted to sRGB; the EXIF resolution tags size the image, or 72 ppi. Image sequences, other codings and grids of cropped tiles are refused with a message, and so is every HEIF file in a build without the feature. The decoder checks image size before allocating (`crates/engine/src/cmd/fileio/tests_heifread.rs`). + - HP-GL/2 plot files (`.plt`, `.hpgl`, `.hgl`, `.hpg`, or a name no other format reads when the file starts with plotter instructions) open and place as paths on one layer, the artboard round the art: each line drawn with the pen down is a stroked path in its pen's color and width, line types become dash patterns, circles, arcs, wedges and Bézier curves are curves, filled and edged rectangles, wedges and polygons (polygon mode) are filled or stroked paths, and encoded polylines, scaling points, user units and rotation are read. Labels, symbol mode's symbols, raster images and hatching aren't drawn and are listed in the warnings (`crates/cad/src/hpgl/tests.rs`, `crates/engine/src/cmd/fileio/tests_hpgl.rs`). - EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export; grayscale TIFFs with an alpha channel or other extra channels place, relink and open, the alpha as the image's transparency (#1466 in part); PSD and PSB files open and place, linked or embedded, as their merged image in every colour mode and depth (#918). EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546). - TIFFs that store each channel as its own plane (PlanarConfiguration 2) place, relink and open as the same image stored pixel by pixel does, 8 and 16 bits: grayscale with an alpha channel or other extra channels, and CMYK with or without them. A planar CMYK TIFF keeps its ink amounts and is stored as a CMYK TIFF with each pixel's inks together, at the file's resolution. A TIFF whose header claims more strips or tiles than can be counted is refused as too large to draw (#1466 in part; `crates/engine/src/cmd/fileio/tests.rs`). - 1-bit TIFFs compressed with CCITT Group 3 (ITU-T T.4) place, relink and open: Compression 2 (modified Huffman) and Compression 3 with one-dimensional rows or, with T4Options bit 0, two-dimensional ones, with or without fill bits, in strips or tiles, with either FillOrder and either polarity. Rows in T.4's uncompressed mode are not read. A file whose pixels would take more than the decoding allowance as RGBA is refused as too large to draw (#1466 in part; `crates/engine/src/cmd/fileio/tests.rs`). @@ -1673,6 +1678,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Plug-in data kept with the document (G17):** a filter writes JSON of at most 1 MiB with `documentData` (one undo step with its objects; `null` removes it) and reads it back in `_context.documentData`, which no other filter is given. It is the document's foreign data (`unknown.pluginData`, by plug-in id), as the perspective grid is, so the native file keeps it with no section of its own; an exporter is given the whole document, so it can read every plug-in's. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_export.rs` (the exporter), `crates/doc/src/plugin_data.rs`, `crates/plugins/src/objects/tests.rs`. Scores and hours unchanged. - **Heavy filter plug-ins (G17):** a filter's manifest may ask for `"budget": "heavy"`: 1 G instructions (20 times a light run's) and 300 s a run, for routing, packing or simulation. Install Plug-in… and File › Open ask the user first, `plugin.install` needs `allowHeavy: true` (`plugin.inspect` tells beforehand), the Additional Plug-ins Folder skips such filters, and the plug-in store keeps the answer. Its dialog has no Preview; the app runs it on a thread of its own with a progress window (Cancel, Continue in Background) and applies the result as `plugin.run` with the same parameters, one undo step, unless the document or the plug-in changed meanwhile. Regressions: `crates/plugins/tests/abi.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. - **Safe Mode for plug-ins (G17):** `vectorcraft --safe-mode` and `vectorcraft-cli --safe-mode <subcommand>` start without plug-ins. When a launch stopped while a plug-in was loading or drawing its first live effect, the next launch names that plug-in, without saying it caused the stop, and offers Disable and Continue, Start Normally or Start in Safe Mode. Help › Safe Mode lists the disabled plug-ins with Enable on Re-launch, which takes effect at the next launch, and the app bar and the window title show Safe Mode. The help page (https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf, pages 65 to 69) describes the same choice between diagnosing and starting normally, a Safe Mode with the problem item off and the rest of the app working, and turning the item back on at the next launch. In Safe Mode no plug-in loads or runs, and a live-effect plug-in draws its object's geometry unchanged while the object keeps the effect, which the Appearance panel marks "(Safe Mode)" with the plug-in's id in its tooltip and `effect.list` marks `skipped`; Expand Appearance refuses such an object. The web build keeps no plug-ins between page loads and has no marker. Each desktop launch with preferences keeps a marker in the `Launch` folder next to them; `vectorcraft-cli` and a desktop launch without preferences keep none, so a stop there leads to no choice at the next launch. Disabled plug-ins are listed in `Plug-ins/disabled.json`. `plugin.safeMode` reports Safe Mode and the disabled plug-ins, and `plugin.setEnabled` changes the list. Regressions: `crates/plugins/src/safe_mode.rs`, `crates/plugins/tests/safe_mode.rs`, `crates/engine/tests/plugin_safe_mode.rs`, `crates/ui-egui/tests/safe_mode.rs`. Scores and hours unchanged. +- **A disabled plug-in whose update stays in the store is turned on:** when `plugin.install` writes a module that lists permissions to the `Plug-ins` folder, its grant can't be written, and the folder can't be returned to the old module (or, on a first install, the new module can't be removed), the folder holds the new module, so a disabled plug-in is turned on, by id and by stored file, as for any install the folder holds. The old grant, or none, doesn't cover the new module, so the next start skips it until it is approved. Regressions: `crates/engine/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in instruction budget at a bulk memory instruction:** a run that pauses where one slice of its instruction budget ends keeps the instructions the slice still held. A `memory.fill` or `memory.copy` that the slice can't pay for in full runs on the next slice, so a run whose budget covers its work finishes; a run ends with `Limit("instruction budget")` only when the instructions it has left can't pay for the next instruction. Regressions: `crates/plugins/tests/abi.rs`. Scores and hours unchanged. - **Bulk memory fuzz cases use the host's fuel constants:** `crates/engine/tests/plugin_fuzz.rs` samples runs around one slice of the instruction budget and lengths around the bytes one unit of fuel pays for with `vectorcraft_plugins`' own `FUEL_SLICE` and `BYTES_COPIED_PER_FUEL`, so the sampled cases move when either value changes. Regressions: `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. - **Plug-in permissions (G17):** a manifest may list `permissions` (`doc.read`, `doc.write`, `state`, `effect.render`, `export.write`; the first three follow storytold/photocraft#662) and an `api` level; without the list a plug-in gets what its kind implies and installs as before. Install Plug-in… and File › Open show the name, id, version, file SHA-256 and each permission in plain words, with control and bidirectional characters escaped, and install nothing on Cancel; for a plug-in that lists permissions, `plugin.install` needs `grant` and folders install it only when it is granted. The grant is kept with the module's hash, a module asking for more asks again, and a filter run refuses output its permissions don't allow. Regressions: `crates/plugins/src/permissions.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. @@ -1702,6 +1708,10 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Evidence for the shared light Color: geometry tests in `tests_lighting`, effect paint tests, command/save/undo/expand and SVG/PDF checks in `three_d_light_color`, and headless picker/reset/preview tests in `tests_revolve`. Revolve keeps smooth colored vector ramps and continuous flat-lit caps. Inflate geometry tests check spherical lighting, shared vertex/edge continuity, concave cell coverage, puffed backs at perspective and legacy single-light/list equivalence; rendered command/export regressions cover smooth shading and editable expansion. Four legacy white-light PNGs match main byte for byte; [release measurements](3d-lighting-performance.md) record the colored solid-fill batch and the remaining source-gradient cost. +## Eyedropper character dimensions (2026-11-11) + +`appearance.copyFrom` reads the source's effective character dimensions and converts them into each target's local text space, preserving target placement, rotation, shear and reflection (#6404). Point and area text, grouped text, highlighted ranges, reverse and remembered sampling, attribute opt-outs, Undo/Redo and native-file round trips are regression-tested. Picks with unrepresentable dimensions or text that would extend beyond the canvas leave the document, paint defaults and remembered sample intact. Paint-only copying remains available. Tests: `crates/engine/src/tests/eyedropper.rs` (`scaled_type_pick_*`). Scores and estimates unchanged. + ## Highlighted-text Eyedropper (2026-10-21) Type/Eyedropper switches preserve highlighted characters; sampling changes only their enabled @@ -1948,6 +1958,10 @@ A VectorCraft addition, not a feature of the reference app, so no area score mov A VectorCraft addition (it replaces a script some users ran), so no area score moves. Object › Plug-ins › Document Tools › Batch Trace Folder (`addon.document.batchTraceFolder`) asks for a folder of images and a destination folder, traces each image with a preset (optionally expanded) and saves it there under its own name as `.vectorcraft`, `.svg`, `.pdf`, `.ai` or `.eps`, without replacing files, closing each image's document again and reporting the images it couldn't trace (#4921). A folder with more than 1,000 entries is refused rather than traced in part. Optional settings reject null and values of the wrong type before opening images. Tests: `cmd::addons::document::tests::batch_trace_folder_traces_and_saves_each_image`, `batch_trace_folder_refuses_a_folder_it_cannot_list_in_full`, and `batch_trace_folder_rejects_wrong_typed_options` (engine), `addons::document::tests::batch_trace_folder_asks_for_both_folders` (ui-egui). +## Image Tools add-on: Remove Background (2026-11-11) + +A VectorCraft addition, so no area score moves. Object › Plug-ins › Image Tools › Remove Background… (`addon.image.removeBackground`) makes the background of the selected images transparent in their own pixels, in the app with no service or model. The background is the most common edge color (nothing is cleared when the edges hold at least as many clear pixels as pixels of that color), the average of the corners that are not clear, or picked colors. Contiguous (on by default) clears it only where it reaches in from an edge; off, it is cleared everywhere. Tolerance and Softness (0 to 100, 10 by default, VectorCraft's own values) set how near a color is cleared and the band over which pixels fade out, measured as RGB distance or, with Match set to Each Channel, as the largest difference on one channel. The run is one undo step, and none is recorded when no pixel changes. The image stays an image: a PNG with alpha at its own bit depth, gray or color as it was, with its color profile and resolution, and a linked image is embedded. CMYK images, linked images that show their preview, images that would need more than the decode allowance and results with no pixel left are refused. The dialog's Preview works as Release Type on a Path's does; its preview steps decode an image once, and the decoded pixels are released when the dialog closes. Tests: `cmd::addons::image::tests` in `vectorcraft-engine` (edge, corner and picked colors, contiguous on and off, a second run, tolerance and softness by distance and by channel, 16-bit and gray images, the color profile and resolution kept, an image read at another size, linked images, refusals and bad params, and decoded pixels reused across preview steps and released at OK and Cancel), `addons::image::tests::remove_background_previews_cancels_and_runs_on_ok` in `vectorcraft-ui-egui` and `command_sweep`. Commands: `docs/mcp.md` › Image Tools add-on. + ## Color Effects add-on: Find Nearest Swatch (2026-11-01) A VectorCraft addition, not a feature of the reference app, so no area score moves. Object › Plug-ins › Color Effects › Find Nearest Swatch… lists each colour of the selection (fills and strokes, as ticked; solid colours and gradient stops, inside groups and type) with the three swatches of the chosen library closest to it by CIEDE2000 difference, with that difference (`addon.color.nearestSwatch`, #4923). The library is the document's swatches, a built-in library, or a library or colour book the user saved or loaded (no colour book data ships with VectorCraft). Apply gives each colour its nearest swatch as one undo step (`addon.color.applyNearestSwatch`): global and spot swatches link, and a library's swatches the colours take are added to the document first, so a spot colour stays spot. Remaining: typing a colour to look up in the dialog (the command takes `color`), and applying a match other than the nearest. Tests: `cmd::addons::color::nearest::tests` (engine), `addons::color::nearest::tests::find_nearest_swatch_lists_the_matches_and_applies_the_nearest` (ui-egui). @@ -2005,6 +2019,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | Eyedropper copies effective character dimensions between transformed text objects (#6404); scores and estimates unchanged. | | 2026-11-11 | minor | Envelopes: with Distort Appearance, type's strokes (characters' and appearance) paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them; paths' strokes, geometry effects, gradients and patterns bend as before; also in an envelope inside an envelope; opacity masks bend as other art; Expand keeps type's strokes as strokes; the visual bounds of envelopes, blends and repeats and the reach of symbol art count type's character strokes as type's own bounds do. Changes how VectorCraft's own envelopes of stroked type draw. Advanced art evidence and `.ai` import evidence updated (#6644); scores and estimates unchanged. | | 2026-11-11 | minor | Bundled content: built-in Energy Beams brush library (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail); brush libraries evidence updated; ~44% → ~46%; more built-in brush libraries remain. | | 2026-11-11 | minor | Bundled content: built-in Skill Icons symbol library (Attack, Defend, Heal, Buff, Ult); symbol libraries evidence updated; ~42% → ~44%; more built-in symbol libraries remain. | @@ -2183,6 +2198,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | 2026-10-22 | minor | G2: Effect › Sketch › Conté Crayon (Foreground Level, Background Level, Texture, Scaling, Relief, Light Direction, Invert; a black crayon in the dark areas and a white one in the light areas on textured mid-grey paper; ranges and defaults unverified VectorCraft choices); measured Photoshop-style filters 57/57, the Effect Gallery remains (4–6 h); area score unchanged (49%, estimated). | | 2026-10-22 | minor | File formats: Affinity import gains master pages, tables, paragraph formats, text scale, shadows, glows, outlines (also on stroked shapes), overlays, underlines, strikethrough and editable embedded documents, strokes styled None left out, CMYK pixel layers through the document's CMYK profile, text placed as Affinity places it (AF.5, from storytold/designcraft#291); synthetic, corpus and local-thumbnail tests; score and hours unchanged (estimated). Hostile files stay bounded: one budget for the whole read (layers, pixels, characters, table cells, embedded documents included), a work budget for overlays, capped master page copies, objects past `MAX_COORD` left out; the import fuzzer and limit tests cover tables, paragraphs, effects, overlays, master pages, CMYK pixels and embedded documents. | | 2026-10-22 | minor | Recolor Artwork Edit chips scroll within the available screen height (#3416); small palettes retain their tab height; scores and estimates unchanged. | +| 2026-10-21 | minor | Width-profile outlines offset samples added inside a flattened chord along the source curve's exact tangent, so a zoomed outline no longer jogs at flatten vertices; a regression test covers added samples. Scores and estimates unchanged. | | 2026-10-22 | minor | Font pickers list recently applied families first (#3515), using the existing recent-font limit; search, filters, previews and keyboard selection share the same rows. Scores and estimates unchanged. | | 2026-10-21 | minor | Plug-ins: filters keep data with the document (`documentData`), as its foreign data, so the native file needs no section of its own (G17); engine and unit regressions. Scores and hours unchanged. | | 2026-10-21 | minor | Print: PDF/X-1a and PDF/X-3 flattening in a CMYK document rasterizes as ink amounts, so CMYK colours under an image with see-through pixels keep their numbers, K included, instead of going through RGB (#2160); own-fixture test; scores and hours unchanged (estimated) | @@ -2199,6 +2215,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | 2026-10-21 | minor | Snap to Glyph (#3124) removed from View navigation missing lists and menu stubs; scores and hours unchanged at this rounding | | 2026-10-21 | minor | Type core: System Layout (Character panel menu, whole document, off by default) is done; scores and hours unchanged | | 2026-10-22 | minor | Ruby (ふりがな) for live text: `type.ruby` sets/removes a reading over a byte range, above or below (right of the column in vertical type), aligned, sized and overhanging or squeezed; saved natively, laid out with line growth, drawn on the canvas and in SVG/PDF (outlines where ruby is present); scores and hours unchanged | +| 2026-10-23 | minor | Width-profile outlines sample adaptively: two samples per profile segment, refined where the width bends, and only where the flattened path does not already sample the width. The sampled edges are fitted with G1 cubics within a tenth of the flattening tolerance (0.001 pt by default) instead of one cubic per flatten step. On the reviewer's 1,500-ellipse fixture (20–200 pt ellipses, 3–12 pt weights) the PDF falls to 3.59 MB and the SVG to 6.79 MB (reported main baselines 4.48 MB and 9.30 MB, about 0.80× and 0.73×), and `profiled_outlines_stay_compact` guards the path-element count. Scores and estimates unchanged. | | 2026-10-21 | minor | 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged | | 2026-10-21 | minor | Properties: one selected clip group has Edit Clipping Path and Edit Contents at the top of the panel (public doc cited) (G1). Scores and hours unchanged. | | 2026-10-21 | minor | Print: the Print dialog's Media list carries the Japanese papers — JIS B4 (257 × 364 mm), JIS B5 (182 × 257 mm) and JIS B6 next to the ISO B4/B5, which keep their ISO 216 sizes, plus はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判; the JSON id or the dialog label names a paper, any case and spacing. Print score and hours unchanged. | @@ -2212,6 +2229,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | 2026-10-19 | minor | Plug-ins: parameters with labels, units, help, groups and conditions in their dialogs, `string` and `numberList` types, up to 256 per plug-in (G17); manifest and kittest dialog regressions. Scores and hours unchanged. | | 2026-10-19 | minor | Plug-ins: exporter plug-ins add write-only file formats to Export As and `document.export` (G17); engine, ABI and headless Export As regressions. Scores and hours unchanged. | | 2026-10-19 | minor | Libraries panel: the list fills the panel and scrolls (also while a drag nears its edges); items drag before or after others of their kind and into or out of groups, and groups drag by their headers (`library.moveItem` `before`/`after`, `library.moveGroup`). Scores and estimates unchanged. | +| 2026-10-19 | minor | Width profiles run as a smooth C1 curve between their points (a monotone cubic Hermite, Fritsch-Carlson) instead of straight lines, sampled along the path so the outline follows it on straight runs; steps between coincident points still step. Regression tests cover easing, monotonicity, locality and steps. Scores and estimates unchanged. | | 2026-10-19 | minor | Cache status-readout sample widths per UI, language, document unit and pixels per point; regressions check reuse across live frames and refresh after display-setting changes. Scores and estimates unchanged. | | 2026-10-19 | minor | Status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates. Contributor original-file acceptance confirmed; scores and estimates unchanged. | | 2026-10-19 | minor | Cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission. Regression coverage; scores and estimates unchanged. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 296826671..c9e043aa6 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (Outline view: images and gradient meshes are picked on what Outline draws, so a click inside them reaches the path behind, #6437; previously minor: a middle-click on a document tab closes it, #6506; previously minor: Blob Brush: a round tip's preview is the outline of the area it adds, round at both ends, as the stroke it commits (#4343); scores and estimates unchanged; previously:; ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (#6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Outline view: images and gradient meshes are picked on what Outline draws, so a click inside them reaches the path behind, #6437; previously minor: a middle-click on a document tab closes it, #6506; previously minor: Blob Brush: a round tip's preview is the outline of the area it adds, round at both ends, as the stroke it commits (#4343); scores and estimates unchanged; previously:; ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). > @@ -302,6 +302,7 @@ tools, never checked side by side) isn't captured row by row. | Space: Hand; Cmd+Space / Cmd+Alt+Space: zoom in/out; middle button pans | done | Space pressed after a tool takes a drag keeps the view still and shows that tool's cursor. Shape and anchor tools may use Space to move the geometry being drawn. Cmd+Space pressed mid-drag likewise keeps the active tool cursor. Space held before a press starts a view gesture, and its view cursor returns after release. `space_during_a_pencil_drag_keeps_the_pencil_cursor`, `cmd_space_mid_tool_drag_keeps_the_active_tool_cursor` | | Document window scroll bars along the right and bottom edges (Normal and Full Screen Mode with Menu Bar; none in Full Screen and Presentation Mode): a thumb as long as the view against the art and the artboards; dragging it scrolls, a click on the track pages, the wheel over a bar scrolls along it; in a rotated view they follow the screen's axes | partial | #2322. The span is the art and the artboards with half a window around them, not Illustrator's 227 in canvas: VectorCraft has no bounded drawing area (#2284). A track click pages rather than jumping to the click; the page size and the rotated-view behaviour are our own choice (the public documentation gives neither) | | Cmd/Ctrl: last-used selection tool; Cmd+Alt: Group Selection inside Direct Selection | done | Cmd+Alt lends Group Selection only while Direct Selection is active (Cmd borrow from the Pen keeps Direct Selection so Alt stays copy/subtract); Cmd alone lends Direct Selection the Selection tool: its bounding box shows while Cmd is held over objects selected whole and its handles scale and rotate them, and a Cmd drag moves the whole object while its direct-selected anchors stay selected (#3901; no box over a selection with anchors picked yet); engine pointer and canvas regressions | +| Eyedropper copies effective character dimensions between transformed text objects | done | Point/area and grouped text, highlighted ranges, reverse/remembered sampling, opt-outs and atomic invalid-transform rejection; `scaled_type_pick_*` (#6404) | | Eyedropper styles highlighted characters only and preserves ranges across Type switches | done | Font/paint, Shift color-only, Unicode, Undo/Redo and stale-range regression tests; enabled non-neutral source or target object transparency is refused atomically; disable Transparency to sample supported attributes | | Eyedropper reads visible sources while their layer/object is locked or outside isolation | done | Ordinary/Shift/Shift+Alt and gradient-stop reads preserve the source and selection; Alt reverse remains editable-only; hidden art stays excluded (`eyedropper_sources.rs`, #1251) | | Add a visible stroke after drawing with inherited None/zero-width paint | done | Paint and Swap Fill/Stroke restore the existing 1 pt default; positive widths and painted zero widths stay as set; Undo/Redo and targeted appearance items tested (`stroke_visibility.rs`, #1253) | @@ -511,6 +512,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | Eyedropper copies effective character dimensions between transformed text objects (#6404); scores and estimates unchanged. | | 2026-11-11 | minor | Outline view: a click on a path's outline selects it through an image or a gradient mesh in front, which Outline draws as a frame or mesh lines (#6437). | | 2026-11-11 | minor | A middle-click on a document tab (or its ×) closes it as the × does; closing a document's last view with unsaved changes asks to save first (#6506). VectorCraft addition; scores and estimates unchanged. | | 2026-11-03 | minor | A double-click on a ruler adds a guide at the pointer (#5239): a vertical one on the top ruler, a horizontal one on the left ruler; scores and estimates unchanged. |