diff --git a/apps/vectorcraft/src/host_services.rs b/apps/vectorcraft/src/host_services.rs index 50f1792eb8..72d13db08f 100644 --- a/apps/vectorcraft/src/host_services.rs +++ b/apps/vectorcraft/src/host_services.rs @@ -378,7 +378,7 @@ pub(crate) fn services(parent: Parent, ui: egui::Context) -> Services { // Load Actions… refuses a file over its size limit without reading all of it. read_head: Some(Box::new(read_file_head)), write: Some(Box::new(write_file)), - exists: Some(Box::new(|p: &str| std::path::Path::new(p).exists())), + exists: Some(std::sync::Arc::new(|p: &str| std::path::Path::new(p).exists())), // Background Save and Export write from a worker thread. write_shared: Some(std::sync::Arc::new(write_file)), // Native saves stream into the file as they are encoded. diff --git a/crates/cad/src/hpgl/write.rs b/crates/cad/src/hpgl/write.rs index 80ac40f866..3e20c69a98 100644 --- a/crates/cad/src/hpgl/write.rs +++ b/crates/cad/src/hpgl/write.rs @@ -18,15 +18,19 @@ //! An HP-GL plot with a coordinate outside −32,768 to 32,767 (16-bit numbers, the range older //! devices read) gets a warning; HP-GL/2's encoded polylines take the wider range. //! The pens are drawn in turn (pen 1's paths, then pen 2's), each in paint order or, with -//! `sort`, each next path the one that starts nearest where the pen stopped. +//! `sort`, each next path the one that starts nearest where the pen stopped. With `inside_first`, +//! a pen draws a path only after every path of its own that lies inside it (a letter's counter +//! before the letter, a hole before the part). //! //! HP-GL writes `IN;`, then for each pen `PU;SP;` and per path `PUx,y;PDx,y,…;`, then `PU;SP0;`. //! HP-GL/2 also writes the palette (`NP` and a `PC` color for each pen) and draws each pen's paths //! as one encoded polyline (`PE`, base 64). mod clip; +mod nest; -use std::collections::HashSet; +use std::cmp::Reverse; +use std::collections::{BinaryHeap, HashSet}; use std::sync::Arc; use kurbo::PathEl; @@ -51,7 +55,8 @@ pub const TOLERANCE_RANGE: std::ops::RangeInclusive = 0.01..=10.0; pub const MAX_PENS: usize = 255; /// The most points the plots of one export hold. const MAX_POINTS: usize = if cfg!(test) { 100_000 } else { 10_000_000 }; -/// The most point comparisons sorting may make, over the whole plot. +/// The most work sorting paths and finding which lie inside others may take, over the whole plot: +/// one for each point or box compared. const MAX_SORT_WORK: u64 = if cfg!(test) { 100_000 } else { 500_000_000 }; /// The most edge tests clipping masks may take, over the whole plot. const MAX_CLIP_TESTS: u64 = if cfg!(test) { 1_000_000 } else { 200_000_000 }; @@ -74,6 +79,8 @@ const OUT_OF_RANGE: &str = "art past 8,388,607 plotter units (about 210 m) from const PAST_CLASSIC_RANGE: &str = "the plot has coordinates past 32,767 plotter units (about 82 cm) from the origin, which older HP-GL devices may not read: HP-GL/2 takes a wider range"; const MANY_COLORS: &str = "the art has more than 255 colors: the colors past those draw with the pen of the nearest color"; const UNSORTED: &str = "the plot has too many paths to sort in full: the rest are drawn in paint order"; +const UNSORTED_INSIDE: &str = "the plot has too many paths to sort in full: the rest are drawn in paint order, each after the paths inside it"; +const UNNESTED: &str = "the plot has too many paths to find in full which lie inside others: from the pen where that stopped, a path may be drawn before the paths inside it"; const TOO_DEEP: &str = "symbols, blends, envelopes and repeats nested more than 8 deep were left out"; const LIVE_LEFT_OUT: &str = "blends, envelopes and repeats make too many objects: the art past 4 million objects was left out"; const SYMBOLS_LEFT_OUT: &str = "symbols placed inside symbols hold too many objects: the instances past that were left out"; @@ -151,6 +158,8 @@ pub struct HpglOptions { pub origin: Origin, /// Draw each pen's paths nearest first, instead of in paint order. pub sort: bool, + /// Draw a path only after the pen's paths that lie inside it. + pub inside_first: bool, /// The area plotted (document space): its bottom-left corner or center is the origin, and the /// art is cut at its edges. pub region: Rect, @@ -158,7 +167,15 @@ pub struct HpglOptions { impl Default for HpglOptions { fn default() -> Self { - Self { version: Version::default(), tolerance: DEFAULT_TOLERANCE, pens: vec![], origin: Origin::default(), sort: false, region: Rect::ZERO } + Self { + version: Version::default(), + tolerance: DEFAULT_TOLERANCE, + pens: vec![], + origin: Origin::default(), + sort: false, + inside_first: false, + region: Rect::ZERO, + } } } @@ -263,14 +280,10 @@ pub fn export_regions(doc: &Document, opts: &HpglOptions, regions: &[Rect]) -> R if !by_pen.iter().all(Vec::is_empty) { pen_warnings.iter().for_each(|w| warn(&mut warnings, w)); } - if opts.sort { + if opts.sort || opts.inside_first { let (mut at, mut work) = ((0, 0), MAX_SORT_WORK); for paths in &mut by_pen { - let (stop, sorted) = nearest_first(paths, at, &mut work); - at = stop; - if !sorted { - warn(&mut warnings, UNSORTED); - } + at = reorder(paths, at, &mut work, opts.sort, opts.inside_first, &mut warnings); } } if opts.version == Version::Hpgl && by_pen.iter().flatten().flatten().any(|(x, y)| !CLASSIC_RANGE.contains(x) || !CLASSIC_RANGE.contains(y)) { @@ -348,25 +361,50 @@ fn box_dist2(p: (i64, i64), b: [i64; 4]) -> i128 { dx * dx + dy * dy } -/// `paths` reordered from `at`: each next path is the one nearest where the pen stopped, by its -/// start or, for an open path (then drawn backwards), its end, or for a closed path any of its -/// points (where it then starts). Comparing points stops once `work` is spent; the paths left keep -/// their order. Returns where the pen stops, and `false` when the work ran out. -fn nearest_first(paths: &mut Vec>, mut at: (i64, i64), work: &mut u64) -> ((i64, i64), bool) { - // Each path with its box. - let mut left: Vec<_> = std::mem::take(paths) - .into_iter() - .map(|p| { - let b = p.iter().fold([i64::MAX, i64::MAX, i64::MIN, i64::MIN], |b, q| [b[0].min(q.0), b[1].min(q.1), b[2].max(q.0), b[3].max(q.1)]); - (p, b) - }) - .collect(); +/// `paths` (one pen's) reordered from `at`. With `nearest`, each next path is the one nearest +/// where the pen stopped, by its start or, for an open path (then drawn backwards), its end, or +/// for a closed path any of its points (where it then starts); without, the first in paint order. +/// With `inside_first`, a path is drawn only once every path lying inside it ([`nest::outer`]) is. +/// Once `work` is spent, the paths left keep paint order, each after the paths inside it with +/// `inside_first`, with a warning. Returns where the pen stops. +fn reorder( + paths: &mut Vec>, + mut at: (i64, i64), + work: &mut u64, + nearest: bool, + inside_first: bool, + warnings: &mut Vec, +) -> (i64, i64) { + let boxes: Vec<[i64; 4]> = paths.iter().map(|p| nest::bounds(p)).collect(); + // The paths each path lies inside, and how many paths inside each are still to draw. + let outer = inside_first.then(|| nest::outer(paths, &boxes, work)).flatten(); + if inside_first && outer.is_none() { + warn(warnings, UNNESTED); + } + let mut waiting = vec![0usize; paths.len()]; + for o in outer.iter().flatten().flatten() { + if let Some(w) = waiting.get_mut(*o) { + *w += 1; + } + } + if !nearest { + if let Some(outer) = &outer { + let all = std::mem::take(paths).into_iter().enumerate().collect(); + inner_first(all, outer, &mut waiting, paths); + } + return at; + } + // Each path with its index and box. + let mut left: Vec<_> = std::mem::take(paths).into_iter().zip(boxes).enumerate().map(|(i, (p, b))| (i, p, b)).collect(); while !left.is_empty() { // (index, reversed, the closed path's point to start at, distance) - let mut best: (usize, bool, Option, i128) = (0, false, None, i128::MAX); - for (i, (p, b)) in left.iter().enumerate() { + let mut best: (usize, bool, Option, i128) = (usize::MAX, false, None, i128::MAX); + for (i, (j, p, b)) in left.iter().enumerate() { let (Some(&s), Some(&e)) = (p.first(), p.last()) else { continue }; *work = work.saturating_sub(1); + if waiting.get(*j).is_some_and(|w| *w > 0) { + continue; + } if s == e && p.len() > 2 { if box_dist2(at, *b) >= best.3 { continue; @@ -387,11 +425,27 @@ fn nearest_first(paths: &mut Vec>, mut at: (i64, i64), work: &mu best = (i, true, None, de); } } - if *work == 0 { - paths.extend(left.into_iter().map(|(p, _)| p)); - return (at, false); + // Spent, or no path free to draw (which only a fault could cause): the rest in paint + // order, each after the paths inside it. + if *work == 0 || best.0 >= left.len() { + match &outer { + Some(outer) => { + warn(warnings, UNSORTED_INSIDE); + inner_first(left.into_iter().map(|(j, p, _)| (j, p)).collect(), outer, &mut waiting, paths); + } + None => { + warn(warnings, UNSORTED); + paths.extend(left.into_iter().map(|(_, p, _)| p)); + } + } + return at; + } + let (j, mut p, _) = left.remove(best.0); + for o in outer.as_ref().and_then(|outer| outer.get(j)).into_iter().flatten() { + if let Some(w) = waiting.get_mut(*o) { + *w = w.saturating_sub(1); + } } - let (mut p, _) = left.remove(best.0); if best.1 { p.reverse(); } else if let Some(k) = best.2 { @@ -408,7 +462,39 @@ fn nearest_first(paths: &mut Vec>, mut at: (i64, i64), work: &mu } paths.push(p); } - (at, true) + at +} + +/// Appends `left` (each path with its index among the pen's paths) to `paths` in paint order, +/// each after the paths lying inside it: `outer` lists the paths each lies inside, and `waiting` +/// counts, for each, the paths inside it still to draw. [`nest::outer`] puts a path only inside +/// larger ones, so every path comes free; a path left waiting by a fault is drawn last. +fn inner_first(left: Vec<(usize, Vec<(i64, i64)>)>, outer: &[Vec], waiting: &mut [usize], paths: &mut Vec>) { + let mut held: Vec>> = (0..outer.len()).map(|_| None).collect(); + let mut free = BinaryHeap::new(); + for (j, p) in left { + if let Some(h) = held.get_mut(j) { + if waiting.get(j).is_none_or(|w| *w == 0) { + free.push(Reverse(j)); + } + *h = Some(p); + } else { + paths.push(p); + } + } + while let Some(Reverse(j)) = free.pop() { + let Some(p) = held.get_mut(j).and_then(Option::take) else { continue }; + paths.push(p); + for &o in outer.get(j).into_iter().flatten() { + if let Some(w) = waiting.get_mut(o) { + *w = w.saturating_sub(1); + if *w == 0 { + free.push(Reverse(o)); + } + } + } + } + paths.extend(held.into_iter().flatten()); } /// HP-GL: `IN;`, each pen's paths as `PU` and `PD`, `PU;SP0;`. diff --git a/crates/cad/src/hpgl/write/nest.rs b/crates/cad/src/hpgl/write/nest.rs new file mode 100644 index 0000000000..eee4630ff3 --- /dev/null +++ b/crates/cad/src/hpgl/write/nest.rs @@ -0,0 +1,357 @@ +//! Which of a pen's paths lie inside which of its closed paths, in plotter units, for drawing the +//! inner paths first. + +/// The box of `p`: `[x0, y0, x1, y1]`. +pub(super) fn bounds(p: &[(i64, i64)]) -> [i64; 4] { + p.iter().fold([i64::MAX, i64::MAX, i64::MIN, i64::MIN], |b, q| [b[0].min(q.0), b[1].min(q.1), b[2].max(q.0), b[3].max(q.1)]) +} + +/// For each of `paths` (`boxes` are their [`bounds`]), the closed paths it lies inside: none of +/// its segments crosses the closed path's line, each part of it between the points where it meets +/// that line lies inside the closed path (by the even-odd rule) or on the line, and at least one +/// part lies inside. A path touching the line of the one around it is inside it, a path crossing +/// it is not, and copies of one outline are not inside each other. A path is tested only against +/// closed paths larger than it (by the area of their box, then the area they enclose, then paint +/// order), so no two paths are each inside the other, and only against the closed paths in the +/// [`Grid`] cell of its box's first corner. `None` once `work` is spent: a grid cell filled and a +/// box compared cost one each, and each segment tested costs the closed path's points for its +/// crossings and again for each part of it. +pub(super) fn outer(paths: &[Vec<(i64, i64)>], boxes: &[[i64; 4]], work: &mut u64) -> Option>> { + let size: Vec<(i128, i128)> = paths + .iter() + .zip(boxes) + .map(|(p, b)| { + let box_area = (i128::from(b[2]) - i128::from(b[0])) * (i128::from(b[3]) - i128::from(b[1])); + (box_area, if closed(p) { twice_area(p) } else { 0 }) + }) + .collect(); + let rings: Vec = (0..paths.len()).filter(|&i| paths.get(i).is_some_and(|p| closed(p))).collect(); + let mut out = vec![vec![]; paths.len()]; + if rings.is_empty() { + return Some(out); + } + let grid = Grid::new(&rings, boxes, work)?; + for (a, (p, ab)) in paths.iter().zip(boxes).enumerate() { + for &r in grid.at(ab[0], ab[1]) { + spend(work, 1)?; + let (Some(rb), Some(ring)) = (boxes.get(r), paths.get(r)) else { continue }; + let smaller = (size.get(a), a) < (size.get(r), r); + if !smaller || rb[0] > ab[0] || rb[1] > ab[1] || rb[2] < ab[2] || rb[3] < ab[3] { + continue; + } + if inside(p, ring, work)? + && let Some(o) = out.get_mut(a) + { + o.push(r); + } + } + } + Some(out) +} + +/// The most cells along each side of a [`Grid`]. +const MAX_GRID_SIDE: usize = 512; +/// The most cells of a [`Grid`] a closed path is listed in; one whose box covers more is listed +/// once, among the wide ones. A closed path has 4 points or more, so the grid lists no more +/// entries than the pen has points. +const MAX_CELLS_PER_PATH: usize = 4; + +/// A grid over the boxes of a pen's closed paths, about one cell for each, up to +/// [`MAX_GRID_SIDE`] a side: each cell lists, in paint order, the closed paths whose box covers +/// it, and the closed paths whose box covers more than [`MAX_CELLS_PER_PATH`] cells are listed +/// apart. A closed path whose box holds a point is among the wide ones or in that point's cell. +struct Grid { + /// The first corner of the closed paths' boxes together. + x0: i128, + y0: i128, + /// Their width and height, plus one. + w: i128, + h: i128, + /// Cells along each side. + side: usize, + /// The cells row by row. + cells: Vec>, + /// The closed paths whose box covers more than [`MAX_CELLS_PER_PATH`] cells. + wide: Vec, +} + +impl Grid { + /// The grid of the closed paths `rings` (indices into `boxes`). `None` once `work` is spent. + fn new(rings: &[usize], boxes: &[[i64; 4]], work: &mut u64) -> Option { + let all = rings + .iter() + .filter_map(|&r| boxes.get(r)) + .fold([i64::MAX, i64::MAX, i64::MIN, i64::MIN], |e, b| [e[0].min(b[0]), e[1].min(b[1]), e[2].max(b[2]), e[3].max(b[3])]); + let side = ((rings.len() as f64).sqrt().ceil() as usize).clamp(1, MAX_GRID_SIDE); + let (x0, y0) = (i128::from(all[0]), i128::from(all[1])); + let mut grid = Self { + x0, + y0, + w: (i128::from(all[2]) - x0 + 1).max(1), + h: (i128::from(all[3]) - y0 + 1).max(1), + side, + cells: vec![vec![]; side * side], + wide: vec![], + }; + for &r in rings { + let Some(b) = boxes.get(r) else { continue }; + let (Some((cx0, cy0)), Some((cx1, cy1))) = (grid.cell(b[0], b[1]), grid.cell(b[2], b[3])) else { continue }; + let covered = (cx1 - cx0 + 1) * (cy1 - cy0 + 1); + if covered > MAX_CELLS_PER_PATH { + spend(work, 1)?; + grid.wide.push(r); + continue; + } + spend(work, covered as u64)?; + for cy in cy0..=cy1 { + for cell in grid.cells.get_mut(cy * side + cx0..=cy * side + cx1).into_iter().flatten() { + cell.push(r); + } + } + } + Some(grid) + } + + /// The column and row of the cell holding `(x, y)`, `None` outside the grid. + fn cell(&self, x: i64, y: i64) -> Option<(usize, usize)> { + let (dx, dy) = (i128::from(x) - self.x0, i128::from(y) - self.y0); + if dx < 0 || dy < 0 || dx >= self.w || dy >= self.h { + return None; + } + let side = self.side as i128; + Some(((dx * side / self.w) as usize, (dy * side / self.h) as usize)) + } + + /// The wide closed paths, then those listed in the cell holding `(x, y)`. + fn at(&self, x: i64, y: i64) -> impl Iterator { + let cell = self.cell(x, y).and_then(|(cx, cy)| self.cells.get(cy * self.side + cx)).map_or(&[][..], Vec::as_slice); + self.wide.iter().chain(cell) + } +} + +/// Takes `n` from `work`; `None`, with `work` at 0, when it has less. +fn spend(work: &mut u64, n: u64) -> Option<()> { + let left = work.checked_sub(n); + *work = left.unwrap_or(0); + left.map(|_| ()) +} + +/// Does `p` start and end at one point, with three other points or more? +fn closed(p: &[(i64, i64)]) -> bool { + p.len() > 3 && p.first() == p.last() +} + +/// Twice the area the closed path `p` encloses. +fn twice_area(p: &[(i64, i64)]) -> i128 { + let sum: i128 = p + .windows(2) + .map(|w| match w { + [a, b] => i128::from(a.0) * i128::from(b.1) - i128::from(b.0) * i128::from(a.1), + _ => 0, + }) + .sum(); + sum.abs() +} + +/// Does `p` lie inside the closed path `ring`, as [`outer`] decides? `None` once `work` is spent. +fn inside(p: &[(i64, i64)], ring: &[(i64, i64)], work: &mut u64) -> Option { + let mut any = false; + for w in p.windows(2) { + let [s, e] = w else { continue }; + spend(work, ring.len() as u64)?; + // Where the segment meets the line without crossing it: its ends and the ring's points on + // it. Between two of these the segment is all inside or all outside. + let mut stops = vec![*s, *e]; + for edge in ring.windows(2) { + let [a, b] = edge else { continue }; + if crosses((*s, *e), (*a, *b)) { + return Some(false); + } + if a != s && a != e && on((*s, *e), *a) { + stops.push(*a); + } + } + let along = |q: &(i64, i64)| { + (i128::from(q.0) - i128::from(s.0)) * (i128::from(e.0) - i128::from(s.0)) + + (i128::from(q.1) - i128::from(s.1)) * (i128::from(e.1) - i128::from(s.1)) + }; + stops.sort_by_key(along); + stops.dedup(); + for part in stops.windows(2) { + let [m, n] = part else { continue }; + spend(work, ring.len() as u64)?; + // The part's middle, doubled so it is whole. + match locate((i128::from(m.0) + i128::from(n.0), i128::from(m.1) + i128::from(n.1)), ring) { + Some(false) => return Some(false), + Some(true) => any = true, + None => {} + } + } + } + Some(any) +} + +/// Which side of the line through `a` and `b` `c` is on: 1 left, -1 right, 0 on it. +fn side(a: (i64, i64), b: (i64, i64), c: (i64, i64)) -> i128 { + let d = (i128::from(b.0) - i128::from(a.0)) * (i128::from(c.1) - i128::from(a.1)) + - (i128::from(b.1) - i128::from(a.1)) * (i128::from(c.0) - i128::from(a.0)); + d.signum() +} + +/// Do segments `u` and `v` cross at a point inside both, each passing from one side of the other +/// to the other side? +fn crosses(u: ((i64, i64), (i64, i64)), v: ((i64, i64), (i64, i64))) -> bool { + side(u.0, u.1, v.0) * side(u.0, u.1, v.1) < 0 && side(v.0, v.1, u.0) * side(v.0, v.1, u.1) < 0 +} + +/// Does `q` lie on segment `u`? +fn on(u: ((i64, i64), (i64, i64)), q: (i64, i64)) -> bool { + let ((x0, y0), (x1, y1)) = u; + side(u.0, u.1, q) == 0 && q.0 >= x0.min(x1) && q.0 <= x0.max(x1) && q.1 >= y0.min(y1) && q.1 <= y0.max(y1) +} + +/// Where the doubled point `q` lies against the closed path `ring`: `Some(true)` inside by the +/// even-odd rule, `Some(false)` outside, `None` on its line. +fn locate(q: (i128, i128), ring: &[(i64, i64)]) -> Option { + let mut inside = false; + for w in ring.windows(2) { + let [a, b] = w else { continue }; + let (a, b) = ((2 * i128::from(a.0), 2 * i128::from(a.1)), (2 * i128::from(b.0), 2 * i128::from(b.1))); + // Which side of the segment's line `q` is on, 0 on it. + let side = (b.0 - a.0) * (q.1 - a.1) - (b.1 - a.1) * (q.0 - a.0); + if side == 0 && q.0 >= a.0.min(b.0) && q.0 <= a.0.max(b.0) && q.1 >= a.1.min(b.1) && q.1 <= a.1.max(b.1) { + return None; + } + // A segment crossing the level of `q` to its right. + if (a.1 > q.1) != (b.1 > q.1) && (side > 0) == (b.1 > a.1) { + inside = !inside; + } + } + Some(inside) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn square(x0: i64, y0: i64, x1: i64, y1: i64) -> Vec<(i64, i64)> { + vec![(x0, y0), (x1, y0), (x1, y1), (x0, y1), (x0, y0)] + } + + fn nest(paths: &[Vec<(i64, i64)>]) -> Option>> { + let boxes: Vec<[i64; 4]> = paths.iter().map(|p| bounds(p)).collect(); + let mut work = u64::MAX; + outer(paths, &boxes, &mut work) + } + + #[test] + fn points_inside_outside_and_on_the_line() { + let ring = square(0, 0, 10, 10); + assert_eq!(locate((10, 10), &ring), Some(true)); + assert_eq!(locate((30, 10), &ring), Some(false)); + assert_eq!(locate((10, -2), &ring), Some(false)); + assert_eq!(locate((20, 10), &ring), None); + assert_eq!(locate((0, 0), &ring), None); + } + + #[test] + fn a_path_inside_another_lies_inside_it_and_not_the_other_way() { + let paths = [square(0, 0, 100, 100), square(10, 10, 20, 20), vec![(30, 30), (40, 40)]]; + assert_eq!(nest(&paths), Some(vec![vec![], vec![0], vec![0]])); + } + + #[test] + fn touching_counts_as_inside_and_crossing_does_not() { + // A square with a corner on the outline's left edge, a triangle whose corners lie on the + // outline's edges, and a square that crosses the outline. + let paths = [square(0, 0, 100, 100), square(0, 40, 20, 60), vec![(0, 50), (50, 0), (100, 50), (0, 50)], square(90, 90, 120, 95)]; + assert_eq!(nest(&paths), Some(vec![vec![], vec![0], vec![0], vec![]])); + } + + /// Every point of a copy lies on the other's line, and none inside it. + #[test] + fn copies_of_one_outline_do_not_lie_inside_each_other() { + let paths = [square(0, 0, 10, 10), square(0, 0, 10, 10), vec![(10, 10), (10, 0), (0, 0), (0, 10), (10, 10)]]; + assert_eq!(nest(&paths), Some(vec![vec![], vec![], vec![]])); + } + + /// A line whose ends and middle lie inside an outline but which crosses a slot cut into it. + #[test] + fn a_line_across_a_slot_is_not_inside() { + let slotted = vec![(0, 0), (100, 0), (100, 100), (14, 100), (14, 40), (12, 40), (12, 100), (0, 100), (0, 0)]; + assert_eq!(nest(&[slotted, vec![(10, 50), (40, 50)]]), Some(vec![vec![], vec![]])); + } + + /// A line that leaves an outline and comes back through two of its corners, crossing none of + /// its sides, and one that runs along the bottom of a slot. + #[test] + fn a_line_through_corners_is_inside_only_where_it_stays_inside() { + let v_slot = vec![(0, 0), (100, 0), (100, 100), (20, 100), (20, 50), (15, 40), (10, 50), (10, 100), (0, 100), (0, 0)]; + assert_eq!(nest(&[v_slot, vec![(5, 50), (40, 50)]]), Some(vec![vec![], vec![]])); + let slot = vec![(0, 0), (100, 0), (100, 100), (20, 100), (20, 50), (10, 50), (10, 100), (0, 100), (0, 0)]; + assert_eq!(nest(&[slot, vec![(5, 50), (40, 50)]]), Some(vec![vec![], vec![0]])); + } + + /// An outline that crosses itself encloses no area by its signed sum, yet a line in one of its + /// lobes lies inside it. + #[test] + fn a_line_inside_a_lobe_of_a_crossed_outline_lies_inside_it() { + let bow = vec![(0, 0), (100, 100), (0, 100), (100, 0), (0, 0)]; + assert_eq!(twice_area(&bow), 0); + assert_eq!(nest(&[bow, vec![(40, 80), (60, 80)]]), Some(vec![vec![], vec![0]])); + } + + #[test] + fn a_ring_inside_a_ring_inside_a_ring_lies_inside_both() { + let paths = [square(20, 20, 80, 80), square(0, 0, 100, 100), square(40, 40, 60, 60)]; + assert_eq!(nest(&paths), Some(vec![vec![1], vec![], vec![0, 1]])); + } + + /// 10,000 parts in rows, each with a hole: each hole is tested against the few closed paths + /// near it, not against every part. + #[test] + fn parts_in_rows_are_tested_only_against_the_closed_paths_near_them() { + let mut paths = vec![]; + for row in 0..100 { + for col in 0..100 { + let (x, y) = (col * 30, row * 30); + paths.push(square(x, y, x + 20, y + 20)); + paths.push(square(x + 5, y + 5, x + 15, y + 15)); + } + } + let boxes: Vec<[i64; 4]> = paths.iter().map(|p| bounds(p)).collect(); + let mut work = 5_000_000; + let found = outer(&paths, &boxes, &mut work); + let want: Vec> = (0..paths.len()).map(|i| if i % 2 == 1 { vec![i - 1] } else { vec![] }).collect(); + assert_eq!(found, Some(want)); + } + + /// 400 closed paths that each cover most of the grid are listed once each, not in every cell + /// they cover. + #[test] + fn closed_paths_covering_many_cells_are_listed_once() { + let mut paths: Vec> = (0..400).map(|i| square(i, i, 1000 + i, 1000 + i)).collect(); + paths.extend((0..400).map(|i| { + let (x, y) = ((i % 20) * 50, (i / 20) * 50); + square(x, y, x + 10, y + 10) + })); + let boxes: Vec<[i64; 4]> = paths.iter().map(|p| bounds(p)).collect(); + let rings: Vec = (0..paths.len()).collect(); + let mut work = u64::MAX; + let Some(grid) = Grid::new(&rings, &boxes, &mut work) else { panic!("the budget ran out") }; + assert_eq!(grid.wide, (0..400).collect::>()); + assert!(grid.cells.iter().map(Vec::len).sum::() <= 400 * MAX_CELLS_PER_PATH); + } + + #[test] + fn finding_what_lies_inside_stops_at_its_budget() { + let paths: Vec> = (0..50).map(|i| square(-i, -i, 100 + i, 100 + i)).collect(); + let boxes: Vec<[i64; 4]> = paths.iter().map(|p| bounds(p)).collect(); + let mut work = 1_000; + assert_eq!(outer(&paths, &boxes, &mut work), None); + assert_eq!(work, 0); + let mut work = u64::MAX; + let found = outer(&paths, &boxes, &mut work); + assert_eq!(found.map(|o| o.iter().map(Vec::len).sum::()), Some(50 * 49 / 2)); + } +} diff --git a/crates/cad/src/hpgl/write/tests.rs b/crates/cad/src/hpgl/write/tests.rs index 0917cf02c9..c29ef67532 100644 --- a/crates/cad/src/hpgl/write/tests.rs +++ b/crates/cad/src/hpgl/write/tests.rs @@ -856,6 +856,142 @@ fn sorting_stops_at_its_budget_and_keeps_every_path() { assert_eq!(text(&out).matches("PD").count(), 600); } +/// `x` points in plotter units. +fn plu(x: f64) -> i64 { + (x * 1016.0 / 72.0).round() as i64 +} + +/// The left edge of each path a plain HP-GL plot draws, in drawing order (plotter units). +fn lefts(out: &HpglOutput) -> Vec { + let mut lefts: Vec = vec![]; + for i in text(out).split_terminator(';') { + let xs = i.get(2..).unwrap_or_default().split(',').step_by(2).filter_map(|x| x.parse::().ok()); + match i.get(..2) { + Some("PU") => lefts.extend(xs.min()), + Some("PD") => { + if let (Some(l), Some(x)) = (lefts.last_mut(), xs.min()) { + *l = (*l).min(x); + } + } + _ => {} + } + } + lefts +} + +/// A letter O made as one path: its outline, then its counter. +#[test] +fn inside_first_cuts_a_letters_counter_before_its_outline() { + let d = doc_with(|d| { + let mut bp = rect(10.0, 10.0, 60.0, 90.0).to_bezpath(); + bp.extend(shapes::ellipse(Rect::new(25.0, 30.0, 45.0, 70.0)).to_bezpath()); + vec![filled(d, PathData::from_bezpath(&bp), Color::BLACK)] + }); + assert_eq!(lefts(&plot(&d, &opts(&d))), [plu(10.0), plu(25.0)]); + assert_eq!(lefts(&plot(&d, &HpglOptions { inside_first: true, ..opts(&d) })), [plu(25.0), plu(10.0)]); +} + +/// A part drawn before its two holes: the holes are cut first, nearest first with `sort`. +#[test] +fn a_part_with_two_holes_is_cut_holes_first() { + let d = doc_with(|d| { + vec![ + stroked(d, rect(10.0, 10.0, 190.0, 90.0), Color::BLACK), + stroked(d, shapes::ellipse(Rect::new(160.0, 40.0, 180.0, 60.0)), Color::BLACK), + stroked(d, shapes::ellipse(Rect::new(20.0, 40.0, 40.0, 60.0)), Color::BLACK), + ] + }); + let (part, right, left) = (plu(10.0), plu(160.0), plu(20.0)); + assert_eq!(lefts(&plot(&d, &opts(&d))), [part, right, left]); + assert_eq!(lefts(&plot(&d, &HpglOptions { inside_first: true, ..opts(&d) })), [right, left, part]); + // Sorting alone starts at the part's corner, nearest the origin; inside first, the nearest + // hole comes first, then the other, then the part. + assert_eq!(lefts(&plot(&d, &HpglOptions { sort: true, ..opts(&d) })), [part, left, right]); + let both = plot(&d, &HpglOptions { sort: true, inside_first: true, ..opts(&d) }); + assert_eq!(lefts(&both), [left, right, part]); + assert!(both.warnings.is_empty(), "{:?}", both.warnings); +} + +/// Rings inside rings are cut innermost first, and a ring with nothing inside it keeps its place +/// in paint order. +#[test] +fn nested_rings_are_cut_innermost_first() { + let d = doc_with(|d| { + vec![ + stroked(d, rect(120.0, 10.0, 190.0, 90.0), Color::BLACK), + stroked(d, rect(10.0, 10.0, 100.0, 90.0), Color::BLACK), + stroked(d, rect(20.0, 20.0, 90.0, 80.0), Color::BLACK), + stroked(d, rect(30.0, 30.0, 80.0, 70.0), Color::BLACK), + ] + }); + let out = plot(&d, &HpglOptions { inside_first: true, ..opts(&d) }); + assert_eq!(lefts(&out), [plu(120.0), plu(30.0), plu(20.0), plu(10.0)]); +} + +/// Each pen's paths are ordered among themselves: a red square inside a black part is drawn with +/// the red pen, after the black part. +#[test] +fn inside_first_orders_each_pens_paths() { + let d = doc_with(|d| { + vec![ + stroked(d, rect(10.0, 10.0, 190.0, 90.0), Color::BLACK), + stroked(d, rect(20.0, 20.0, 40.0, 40.0), Color::rgb8(255, 0, 0)), + stroked(d, rect(50.0, 20.0, 70.0, 40.0), Color::BLACK), + ] + }); + let out = plot(&d, &HpglOptions { inside_first: true, ..opts(&d) }); + assert_eq!(lefts(&out), [plu(50.0), plu(10.0), plu(20.0)]); +} + +/// Finding which of 100 nested rings lie inside which is past the budget: they are drawn in +/// paint order, with a warning. +#[test] +fn inside_first_past_its_budget_draws_in_paint_order() { + let d = doc_with(|d| (0..100).map(|i| stroked(d, rect(1.0 + 0.4 * f64::from(i), 10.0, 199.0, 90.0), Color::BLACK)).collect()); + let out = plot(&d, &HpglOptions { inside_first: true, ..opts(&d) }); + assert_eq!(out.warnings, [UNNESTED]); + let l = lefts(&out); + assert_eq!(l.len(), 100); + assert!(l.windows(2).all(|w| w[0] <= w[1]), "{l:?}"); + // With `sort` too, the budget is spent before sorting starts: paint order, with both warnings. + let out = plot(&d, &HpglOptions { sort: true, inside_first: true, ..opts(&d) }); + assert_eq!(out.warnings, [UNNESTED, UNSORTED]); + assert_eq!(lefts(&out), l); +} + +/// Sorting 600 lines inside two rings runs out of work: every path is drawn, the inner ring and +/// then the outer one last. +#[test] +fn inside_first_past_its_sorting_budget_keeps_the_rings_last() { + let d = doc_with(|d| { + let mut v = vec![stroked(d, rect(0.5, 5.0, 150.0, 30.0), Color::BLACK), stroked(d, rect(0.7, 7.0, 140.0, 25.0), Color::BLACK)]; + v.extend((0..600).map(|i| stroked(d, line((1.0 + (i % 100) as f64, 10.0), (1.0 + (i % 100) as f64, 20.0)), Color::BLACK))); + v + }); + let out = plot(&d, &HpglOptions { sort: true, inside_first: true, ..opts(&d) }); + assert_eq!(out.warnings, [UNSORTED_INSIDE]); + let l = lefts(&out); + assert_eq!(l.len(), 602); + assert_eq!(l.get(600..), Some(&[plu(0.7), plu(0.5)][..])); +} + +/// Without `sort`, 1,000 lines inside a ring are drawn in paint order and the ring after them, +/// within the budget. +#[test] +fn inside_first_alone_keeps_paint_order_for_many_paths() { + let d = doc_with(|d| { + let mut v = vec![stroked(d, rect(0.5, 5.0, 199.0, 30.0), Color::BLACK)]; + v.extend((0..1000).map(|i| stroked(d, line((1.0 + 0.19 * f64::from(i), 10.0), (1.0 + 0.19 * f64::from(i), 20.0)), Color::BLACK))); + v + }); + let out = plot(&d, &HpglOptions { inside_first: true, ..opts(&d) }); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + let l = lefts(&out); + assert_eq!(l.len(), 1001); + assert_eq!(l.last(), Some(&plu(0.5))); + assert!(l.get(..1000).is_some_and(|l| l.windows(2).all(|w| w[0] < w[1])), "{l:?}"); +} + #[test] fn art_wholly_past_the_plotter_range_is_cut_off_with_a_warning() { let d = doc_with(|d| vec![stroked(d, line((7e8, 50.0), (7e8 + 10.0, 50.0)), Color::BLACK)]); diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index 05968ced7e..4e51777060 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -1051,7 +1051,8 @@ fn pinned(spec: &BlendSpec, keys: usize, n: usize) -> Option> { /// - a pinned spine (`key_anchors`): the stored spine's first subpath with each key's anchor (and /// its handles) moved onto the key's centre, so moving a key moves its end of the spine; /// - a spine from Replace Spine: its first subpath, `None` for the anchors (the keys spread -/// evenly along it by arc length). +/// evenly along it by arc length) while the keys sit there; once one is moved off, pinned as +/// above at the anchors where they were spread. /// /// `None` for a blend made without a spine ([`BlendSpec::no_spine`]). pub fn blend_spine(keys: &[Arc], spec: &BlendSpec) -> Option<(PathData, Option>)> { @@ -1077,7 +1078,24 @@ fn layout_spine(keys: &[Arc], spec: &BlendSpec) -> Option<(PathData, Optio return Some((PathData::single(SubPath::polyline(¢ers, false)), Some((0..keys.len()).collect()))); }; let mut first = stored.subpaths.first()?.clone(); - let Some(ka) = pinned(spec, keys.len(), first.anchors.len()) else { return Some((PathData::single(first), None)) }; + let Some(ka) = pinned(spec, keys.len(), first.anchors.len()) else { + let mut path = PathData::single(first); + // A key moved off where Replace Spine spread it takes its point of the spine along, as + // on a pinned spine; otherwise the steps would put it back on the spine (#6809). + if Spine::new(&path).is_none_or(|sp| keys_spread_along(keys, &sp)) { + return Some((path, None)); + } + let Some(mut ka) = spread_anchors(&mut path, keys.len()) else { return Some((path, None)) }; + let mut first = path.subpaths.into_iter().next()?; + open_at_start(&mut first, &mut ka); + for (k, &ai) in keys.iter().zip(&ka) { + if let Some(a) = first.anchors.get_mut(ai) { + let d = center_of(k) - a.p; + a.translate(d); + } + } + return Some((PathData::single(first), Some(ka))); + }; for (k, &ai) in keys.iter().zip(&ka) { let c = center_of(k); if let Some(a) = first.anchors.get_mut(ai) { @@ -1091,21 +1109,62 @@ fn layout_spine(keys: &[Arc], spec: &BlendSpec) -> Option<(PathData, Optio /// [`blend_spine`] with every key on an anchor: a spine from Replace Spine gets anchors where its /// keys sit (by arc length), so editing it keeps them in place. pub fn pin_spine(keys: &[Arc], spec: &BlendSpec) -> Option<(PathData, Vec)> { - let (mut path, anchors) = blend_spine(keys, spec)?; - if let Some(a) = anchors { - return Some((path, a)); + // A spine from Replace Spine is pinned as stored, even with a key moved off it (where + // [`blend_spine`] moves that key's point along): the layout is the same. + if keys.len() >= 2 + && let Some(first) = stored_spine(spec).and_then(|s| s.subpaths.first()) + && pinned(spec, keys.len(), first.anchors.len()).is_none() + { + let mut path = PathData::single(first.clone()); + let mut out = spread_anchors(&mut path, keys.len())?; + // A closed spine's end keys get points of their own, so that once pinned each still + // moves only its own end. + let mut sp = path.subpaths.into_iter().next()?; + open_at_start(&mut sp, &mut out); + return Some((PathData::single(sp), out)); } + let (path, anchors) = blend_spine(keys, spec)?; + Some((path, anchors?)) +} + +/// Do `keys` sit where a spine from Replace Spine spreads them: evenly along `spine` by arc length +/// (within a hundredth of a point, as [`anchor_at_length`] matches anchors)? +fn keys_spread_along(keys: &[Arc], spine: &Spine) -> bool { let k = keys.len(); - let total = Spine::new(&path)?.length(); + keys.iter().enumerate().all(|(i, key)| { + let f = if k > 1 { i as f64 / (k - 1) as f64 } else { 0.0 }; + center_of(key).distance(spine.at(f).0) < 0.01 + }) +} + +/// Open closed spine `sp` at its first anchor when the last key sits there with the first (as +/// [`spread_anchors`] puts them): a copy of that anchor at the end takes the closing segment and +/// the last key, so the two end keys move their ends of the spine apart. +fn open_at_start(sp: &mut SubPath, ka: &mut [usize]) { + let Some(&start) = sp.anchors.first() else { return }; + if !sp.closed || ka.len() < 2 { + return; + } + if let Some(last) = ka.last_mut().filter(|a| **a == 0) { + *last = sp.anchors.len(); + sp.anchors.push(start); + sp.closed = false; + } +} + +/// The anchors of `path`'s first subpath that `k` keys spread evenly along it by arc length sit +/// on, splitting segments where none is. +fn spread_anchors(path: &mut PathData, k: usize) -> Option> { + let total = Spine::new(path)?.length(); let closed = path.subpaths.first().is_some_and(|s| s.closed); let mut out = vec![0usize]; for i in 1..k.saturating_sub(1) { let s = total * i as f64 / (k - 1) as f64; - out.push(anchor_at_length(&mut path, s)?); + out.push(anchor_at_length(path, s)?); } let last = path.subpaths.first().map_or(0, |sp| if closed { 0 } else { sp.anchors.len().saturating_sub(1) }); out.push(last); - Some((path, out)) + Some(out) } /// The anchor of the first subpath at arc length `s`: an existing one within a hundredth of a diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 05f9c29041..da845ba739 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -52,7 +52,7 @@ pub struct TextObject { /// the object, so the runs (and their styles) stay the text itself. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub ruby: Vec, - #[serde(default)] + #[serde(default = "ParaStyle::omitted")] pub para: ParaStyle, /// Per-paragraph attributes (see the type's docs); empty = every paragraph uses `para`. #[serde(default, skip_serializing_if = "Vec::is_empty")] diff --git a/crates/doc/src/text/para_style.rs b/crates/doc/src/text/para_style.rs index f23de1d41f..bb3725072e 100644 --- a/crates/doc/src/text/para_style.rs +++ b/crates/doc/src/text/para_style.rs @@ -244,9 +244,21 @@ pub struct ParaStyle { /// Insert Kashidas (Paragraph panel): justified lines lengthen their Arabic words with /// kashidas before they widen their word spaces. pub kashida: Kashida, + /// Korean Line Break Anywhere (Paragraph panel menu): a line may break between any two Hangul + /// syllables. Off, a Korean word (the characters between spaces, when any of them is Hangul) + /// breaks only at its spaces and punctuation, or where it is wider than the line. New type and + /// imported text have it off; paragraphs saved before it existed open with it on, the line + /// breaks they were laid out with. + pub korean_line_break_anywhere: bool, } impl ParaStyle { + /// The attributes a text object saved without `para` reads with: the defaults, with Korean + /// Line Break Anywhere on, as a saved paragraph without that field reads. + pub(crate) fn omitted() -> Self { + Self { korean_line_break_anywhere: true, ..Self::default() } + } + /// Keeps the indents and the space before and after to -[`crate::MAX_COORD`] to /// [`crate::MAX_COORD`], and the tab stop positions to 0 to 100,000 pt, the range /// `text.tabs.set` takes. A value outside its range becomes the nearer end, and one that is @@ -330,6 +342,10 @@ struct ParaStyleFile { roman_hanging: bool, #[serde(default, skip_serializing_if = "crate::skip::is_default")] kashida: Kashida, + /// Written only when off: a paragraph saved without it was laid out breaking between Hangul + /// syllables. + #[serde(default = "crate::yes", skip_serializing_if = "crate::skip::is_true")] + korean_line_break_anywhere: bool, } impl From for ParaStyleFile { @@ -361,6 +377,7 @@ impl From for ParaStyleFile { kinsoku_type, roman_hanging, kashida, + korean_line_break_anywhere, .. } = p; Self { @@ -386,6 +403,7 @@ impl From for ParaStyleFile { kinsoku_type, roman_hanging, kashida, + korean_line_break_anywhere, } } } @@ -415,6 +433,7 @@ impl From for ParaStyle { kinsoku_type, roman_hanging, kashida, + korean_line_break_anywhere, .. } = f; Self { @@ -439,6 +458,7 @@ impl From for ParaStyle { kinsoku_type, roman_hanging, kashida, + korean_line_break_anywhere, } } } diff --git a/crates/doc/src/text/tests.rs b/crates/doc/src/text/tests.rs index aa403b4218..3b3781f87c 100644 --- a/crates/doc/src/text/tests.rs +++ b/crates/doc/src/text/tests.rs @@ -184,6 +184,29 @@ fn justification_options_read_from_a_file_stay_in_range() { assert_eq!(same, d); } +/// Korean Line Break Anywhere is off for a new paragraph and saved only when off; a paragraph +/// saved before it existed, or a text object saved without paragraph attributes, reads with it on, +/// and both values read back as saved. +#[test] +fn korean_line_break_anywhere_is_saved_only_when_off() { + let off = ParaStyle::default(); + assert!(!off.korean_line_break_anywhere); + let saved = serde_json::to_value(&off).unwrap(); + assert_eq!(saved["korean_line_break_anywhere"], false); + assert_eq!(serde_json::from_value::(saved).unwrap(), off); + let on = ParaStyle { korean_line_break_anywhere: true, ..ParaStyle::default() }; + let saved = serde_json::to_value(&on).unwrap(); + assert!(saved.get("korean_line_break_anywhere").is_none()); + assert_eq!(serde_json::from_value::(saved).unwrap(), on); + let old: ParaStyle = serde_json::from_str(r#"{"justify":"Left"}"#).unwrap(); + assert!(old.korean_line_break_anywhere); + let mut saved = serde_json::to_value(TextObject::point(Point::ZERO, "가나", CharStyle::default())).unwrap(); + assert_eq!(saved["para"]["korean_line_break_anywhere"], false); + saved.as_object_mut().unwrap().remove("para"); + let old: TextObject = serde_json::from_value(saved).unwrap(); + assert_eq!(old.para, ParaStyle { korean_line_break_anywhere: true, ..ParaStyle::default() }); +} + /// Numbered lists: each number style writes its numbers and rolls over (letters after Z, /// roman numerals in subtractive forms, zero-padded numbers past their digits), the marker adds /// a period, and the count runs through list paragraphs, skips empty and bulleted ones and diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 7577a19484..fb86fa7852 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -32,8 +32,8 @@ use super::opacitymask::percent; use super::paint::paint_from; use super::{AppearanceAttrs, EyedropperAttrs, FillAttrs, StrokeAttrs}; use super::{ - CommandSpec, NodeId, Result, Session, bad, check_numbers, check_switches, cmd, has_doc, has_selection, id_param, ids_param, index, leaf_targets, - ok, paint_targets, str_param, switch_or, targets, + CommandSpec, NodeId, Result, Session, bad, check_numbers, check_switches, cmd, has_doc, has_selection, ids_param, index, leaf_targets, + object_param, ok, paint_targets, str_param, switch_or, targets, }; use crate::EngineError; @@ -726,8 +726,8 @@ fn clear(n: &mut Node) { fn transfer(s: &mut Session, p: &Value) -> Result { const C: &str = "appearance.transfer"; - let source = id_param(p, "source").ok_or_else(|| bad(C, "missing `source` id"))?; - let target = id_param(p, "target").ok_or_else(|| bad(C, "missing `target` id"))?; + let source = object_param(p, "source", C)?.ok_or_else(|| bad(C, "missing `source` id"))?; + let target = object_param(p, "target", C)?.ok_or_else(|| bad(C, "missing `target` id"))?; if source == target { return Err(bad(C, "`source` and `target` are the same object")); } @@ -1201,7 +1201,7 @@ fn target_contents(s: &mut Session, p: &Value) -> Result { /// 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"))?; + let clicked = object_param(p, "source", C)?.ok_or_else(|| bad(C, "missing `source` id"))?; let (reverse, append) = (switch_or(p, "reverse", false, C)?, switch_or(p, "append", false, C)?); let defaults_only = switch_or(p, "defaultsOnly", false, C)?; if defaults_only && (reverse || append || p.get("ids").is_some()) { diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 60a6b81378..53e1b655a1 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -29,8 +29,8 @@ use super::edit::{outermost, selected_roots}; use super::paint::paint_from; use super::pathops::{Beside, shape_node}; use super::{ - CommandSpec, EngineError, Result, Session, always, bad, check_numbers, cmd, color_value, f64_or, finite_numbers, has_doc, id_param, named_ids, - number_or, point_param, str_param, switch_or, + CommandSpec, EngineError, Result, Session, always, bad, check_numbers, cmd, color_value, f64_or, finite_numbers, has_doc, named_ids, number_or, + object_param, point_param, str_param, switch_or, }; mod live_paint; diff --git a/crates/engine/src/cmd/buildcmds/live_paint.rs b/crates/engine/src/cmd/buildcmds/live_paint.rs index 3f9b62df21..e5e7757486 100644 --- a/crates/engine/src/cmd/buildcmds/live_paint.rs +++ b/crates/engine/src/cmd/buildcmds/live_paint.rs @@ -1,7 +1,7 @@ use super::{ Appearance, Arc, Beside, Document, EDGE_NAME, EngineError, FACE_NAME, GapOptions, GapSize, LIVE_PAINT_NAME, Node, NodeId, Paint, PathData, Point, - Rect, Result, SOURCES_NAME, Selection, Session, SubPath, Value, b, bad, build_children, edge_near, face_at, finite, id_param, ids_json, - is_live_paint, json, number_or, paint_from, po, point_param, repaint_from, replace_roots, root_ids, source_nodes, source_shapes, switch_or, + Rect, Result, SOURCES_NAME, Selection, Session, SubPath, Value, b, bad, build_children, edge_near, face_at, finite, ids_json, is_live_paint, + json, number_or, object_param, paint_from, po, point_param, repaint_from, replace_roots, root_ids, source_nodes, source_shapes, switch_or, }; /// `options` as stored on a Live Paint group: none for the defaults. @@ -303,7 +303,7 @@ pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result { }; crate::cmd::paint::check_keep_model(p, C)?; let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.fill.clone()); - let group = id_param(p, "group"); + let group = object_param(p, "group", C)?; let roots = if group.is_none() { root_ids(s, p)? } else { vec![] }; let (g, face) = s.edit("Live Paint Bucket", |d, sel| { let gid = match group { @@ -367,7 +367,7 @@ pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result { pub(super) fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result { const C: &str = "livePaint.strokeEdge"; let pt = point_param(p, "point").filter(|q| finite(*q)).ok_or_else(|| bad(C, "missing point [x,y]"))?; - let g = id_param(p, "group").ok_or_else(|| bad(C, "missing group"))?; + let g = object_param(p, "group", C)?.ok_or_else(|| bad(C, "missing group"))?; crate::cmd::paint::check_keep_model(p, C)?; let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.stroke.clone()); let width = number_or(p, "width", s.paint.stroke_width.max(0.0), C)?; @@ -390,7 +390,10 @@ pub(super) fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result { } pub(super) fn lp_info(s: &mut Session, p: &Value) -> Result { - let g = id_param(p, "group").or_else(|| id_param(p, "id")).ok_or_else(|| bad("livePaint.info", "missing group"))?; + let g = match object_param(p, "group", "livePaint.info")? { + Some(g) => g, + None => object_param(p, "id", "livePaint.info")?.ok_or_else(|| bad("livePaint.info", "missing group"))?, + }; let d = &s.doc()?.doc; let n = d.node(g).filter(|n| is_live_paint(n)).ok_or_else(|| bad("livePaint.info", format!("{g} is not a Live Paint group")))?; let faces: Vec = b::faces(n) diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 3c829e8a3e..37473cc59f 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -783,12 +783,14 @@ fn text_create(s: &mut Session, p: &Value) -> Result { /// The paragraph attributes new type gets (the Type tools, `text.create`): aligned to the start of /// each paragraph's direction (left, right for Hebrew and Arabic), Japanese punctuation composed -/// with Line-end Punctuation Half Width and Standard burasagari. Imported text keeps its own. +/// with Line-end Punctuation Half Width and Standard burasagari, and Korean words kept whole +/// (Korean Line Break Anywhere off). Imported text keeps its own. pub(crate) fn new_type_para() -> vectorcraft_doc::ParaStyle { vectorcraft_doc::ParaStyle { justify: vectorcraft_doc::Justify::Auto, mojikumi: vectorcraft_doc::Mojikumi::LineEndHalf, burasagari: vectorcraft_doc::Burasagari::Standard, + korean_line_break_anywhere: false, ..Default::default() } } diff --git a/crates/engine/src/cmd/distortcmds.rs b/crates/engine/src/cmd/distortcmds.rs index 17638477a9..2dbb7a9a18 100644 --- a/crates/engine/src/cmd/distortcmds.rs +++ b/crates/engine/src/cmd/distortcmds.rs @@ -15,8 +15,8 @@ use vectorcraft_tools::distort::{PinSet, arap, collect_points, mesh_for, project use super::edit::{duplicate_in, outermost, refuse_symbol_part, selected_roots}; use super::{ - CommandSpec, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, f64_req, finite_numbers, has_doc, has_selection, id_param, - matrix_param, named_id, named_ids, number_or, ok, point_indices, point_param, str_param, switch_or, targets, + CommandSpec, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, f64_req, finite_numbers, has_doc, has_selection, matrix_param, + named_id, named_ids, number_or, object_param, ok, point_indices, point_param, str_param, switch_or, targets, }; use crate::EngineError; @@ -305,7 +305,7 @@ fn same_points(a: Option<&WidthProfile>, b: Option<&WidthProfile>) -> bool { fn width_point_set(s: &mut Session, p: &Value) -> Result { const C: &str = "stroke.widthPoint.set"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let t = f64_req(p, "t", C)?.clamp(0.0, 1.0); let (left, right) = (f64_req(p, "left", C)?, f64_req(p, "right", C)?); if !(left.is_finite() && right.is_finite()) || left < 0.0 || right < 0.0 || left.max(right) > 1.0e5 { @@ -362,7 +362,7 @@ fn width_point_set(s: &mut Session, p: &Value) -> Result { fn width_point_copy(s: &mut Session, p: &Value) -> Result { const C: &str = "stroke.widthPoint.copy"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let t = f64_req(p, "t", C)?.clamp(0.0, 1.0); // When the profile comes out as it was (the copy replaces a side of a discontinuous point that has // its widths already), the document and its undo history stay as they are. @@ -382,7 +382,7 @@ fn width_point_copy(s: &mut Session, p: &Value) -> Result { fn width_point_remove(s: &mut Session, p: &Value) -> Result { const C: &str = "stroke.widthPoint.remove"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let indices = match (p.get("indices"), p.get("index")) { (Some(a), _) => point_indices(a), (None, Some(i)) => point_indices(&json!([i])), diff --git a/crates/engine/src/cmd/draw2.rs b/crates/engine/src/cmd/draw2.rs index 7d1c1208d8..d469c7a613 100644 --- a/crates/engine/src/cmd/draw2.rs +++ b/crates/engine/src/cmd/draw2.rs @@ -18,8 +18,8 @@ use super::create::{add_look, insert_look}; use super::newart::NewArt; use super::pathops::{Beside, remove_each}; use super::{ - CommandSpec, Result, Session, bad, check_numbers, cmd, f64_or, has_anchors, has_doc, has_selection, id_param, index, number_or, ok, str_param, - switch_or, + CommandSpec, Result, Session, bad, check_numbers, cmd, f64_or, has_anchors, has_doc, has_selection, index, number_or, object_param, ok, + str_param, switch_or, }; use crate::EngineError; diff --git a/crates/engine/src/cmd/draw2/anchors.rs b/crates/engine/src/cmd/draw2/anchors.rs index dbca59b00d..10254f3cd3 100644 --- a/crates/engine/src/cmd/draw2/anchors.rs +++ b/crates/engine/src/cmd/draw2/anchors.rs @@ -1,7 +1,7 @@ use super::common::{MAX_INPUT, path_kind, path_mut, point_entry, usize_opt, usize_req, within_input_bound}; use super::{ Anchor, BTreeSet, Beside, Color, Document, EngineError, Node, NodeId, NodeKind, Paint, PathData, Point, Result, Selection, Session, Value, Vec2, - add_look, bad, check_numbers, f64_or, id_param, json, number_or, ok, po, str_param, switch_or, + add_look, bad, check_numbers, f64_or, json, number_or, object_param, ok, po, str_param, switch_or, }; // ---------- Curvature ---------- @@ -41,7 +41,7 @@ enum CurvatureOp { pub(super) fn curvature_edit(s: &mut Session, p: &Value) -> Result { use vectorcraft_tools::draw2::{EndCurve, curvature_close, curvature_extend, curvature_insert, curvature_move}; const C: &str = "path.curvatureEdit"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let si = usize_opt(p, "subpath", C)?.unwrap_or(0); check_numbers(p, &["x", "y", "t"], C)?; let at = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) { @@ -106,7 +106,7 @@ pub(super) fn curvature_edit(s: &mut Session, p: &Value) -> Result { pub(super) fn remove_anchor(s: &mut Session, p: &Value) -> Result { const C: &str = "path.removeAnchor"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let si = usize_opt(p, "subpath", C)?.unwrap_or(0); let ai = usize_req(p, "anchor", C)?; let curvature = switch_or(p, "curvature", false, C)?; @@ -220,7 +220,7 @@ fn remove_anchors_of( pub(super) fn convert_anchor(s: &mut Session, p: &Value) -> Result { const C: &str = "path.convertAnchor"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let si = usize_opt(p, "subpath", C)?.unwrap_or(0); let ai = usize_req(p, "anchor", C)?; let smooth = str_param(p, "to") == Some("smooth"); @@ -257,7 +257,7 @@ pub(super) fn convert_anchor(s: &mut Session, p: &Value) -> Result { pub(super) fn reshape_segment(s: &mut Session, p: &Value) -> Result { const C: &str = "path.reshapeSegment"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let si = usize_opt(p, "subpath", C)?.unwrap_or(0); let seg = usize_req(p, "segment", C)?; let t = number_or(p, "t", 0.5, C)?.clamp(0.05, 0.95); diff --git a/crates/engine/src/cmd/draw2/cut.rs b/crates/engine/src/cmd/draw2/cut.rs index 126fa57a1b..645c184b14 100644 --- a/crates/engine/src/cmd/draw2/cut.rs +++ b/crates/engine/src/cmd/draw2/cut.rs @@ -4,7 +4,7 @@ use super::common::{ }; use super::{ Anchor, AnchorKind, Beside, BezPath, BoolOp, Cap, EngineError, FillRule, Join, NodeId, ParamCurve, PathData, Point, Result, Session, SubPath, - Value, bad, eraser_area, eraser_tip, f64_or, fill_contains, id_param, json, number_or, po, + Value, bad, eraser_area, eraser_tip, f64_or, fill_contains, json, number_or, object_param, po, }; /// Split a filled path into connected pieces (each outer contour with its holes). @@ -170,7 +170,7 @@ pub(in super::super) fn erase_outline(pd: &PathData, step: f64, remove: &dyn Fn( pub(super) fn split(s: &mut Session, p: &Value) -> Result { const C: &str = "path.split"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let si = usize_opt(p, "subpath", C)?.unwrap_or(0); let anchor = usize_opt(p, "anchor", C)?; let seg = usize_opt(p, "segment", C)?; diff --git a/crates/engine/src/cmd/draw2/freehand.rs b/crates/engine/src/cmd/draw2/freehand.rs index 660f52121e..f13b0a0ba4 100644 --- a/crates/engine/src/cmd/draw2/freehand.rs +++ b/crates/engine/src/cmd/draw2/freehand.rs @@ -4,7 +4,8 @@ use super::common::{ }; use super::{ Anchor, Appearance, BTreeSet, BoolOp, Cap, Color, Document, EngineError, FillRule, Join, NewArt, Node, NodeId, NodeKind, Paint, PathData, Point, - Result, Selection, Session, SimplifyOptions, SubPath, Value, bad, id_param, insert_look, json, number_or, po, remove_each, str_param, switch_or, + Result, Selection, Session, SimplifyOptions, SubPath, Value, bad, insert_look, json, number_or, object_param, po, remove_each, str_param, + switch_or, }; // ---------- Pencil / Paintbrush ---------- @@ -158,7 +159,7 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result { let keep_selected = switch_or(p, "keepSelected", true, C)?; let filled = switch_or(p, "fill", false, C)?; if let Some(ext) = p.get("extend").filter(|v| !v.is_null()) { - let id = id_param(ext, "id").ok_or_else(|| bad(C, "extend needs id"))?; + let id = object_param(ext, "id", C)?.ok_or_else(|| bad(C, "extend needs id"))?; let at_start = str_param(ext, "end") == Some("start"); let (pd, _) = path_of(&s.doc()?.doc, id)?; let si = pd.subpaths.iter().rposition(|s| !s.closed && !s.anchors.is_empty()).ok_or_else(|| bad(C, "path has no open end"))?; @@ -179,7 +180,7 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result { // open subpath, that subpath starting at the end the stroke ends on, it ends at its end). let connect = match p.get("connect").filter(|v| !v.is_null()) { Some(cn) => { - let other = id_param(cn, "id").ok_or_else(|| bad(C, "connect needs id"))?; + let other = object_param(cn, "id", C)?.ok_or_else(|| bad(C, "connect needs id"))?; if other == id { return Err(bad(C, "connect needs a path other than the one extended")); } @@ -299,7 +300,7 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result { return Ok(json!({ "id": id.0 })); } if let Some(rd) = p.get("redraw").filter(|v| !v.is_null()) { - let id = id_param(rd, "id").ok_or_else(|| bad(C, "redraw needs id"))?; + let id = object_param(rd, "id", C)?.ok_or_else(|| bad(C, "redraw needs id"))?; let within = number_or(rd, "within", 12.0, C)?; if !within.is_finite() || within < 0.0 { return Err(bad(C, "redraw within must be a finite distance of 0 or more")); diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 108b8593c9..4f3dc3403a 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -10,8 +10,8 @@ use vectorcraft_geom::{Affine, Point, Vec2}; use super::clipboard::SwatchChoices; use super::{ CommandSpec, Result, Session, always, bad, can_clear_history, can_redo, can_undo, check_numbers, checked_ids_param, clock_date, cmd, date_param, - f64_req, finite_numbers, has_clipboard, has_doc, has_selection, has_selection_guides_or_artboard_tool, has_selection_or_guides, id_param, index, - number_or, ok, point_param, str_param, switch_or, + f64_req, finite_numbers, has_clipboard, has_doc, has_selection, has_selection_guides_or_artboard_tool, has_selection_or_guides, index, number_or, + object_param, ok, point_param, str_param, switch_or, }; use crate::{Clipboard, EngineError, inspect}; @@ -267,7 +267,7 @@ fn doc_move(s: &mut Session, p: &Value) -> Result { } fn doc_node(s: &mut Session, p: &Value) -> Result { - let id = id_param(p, "id").ok_or_else(|| bad("document.node", "missing id"))?; + let id = object_param(p, "id", "document.node")?.ok_or_else(|| bad("document.node", "missing id"))?; let mut opts = slice_opts(p, "document.node")?; let summary = switch_or(p, "summary", false, "document.node")?; if !summary && (opts.depth.is_some() || opts.child_limit.is_some()) { diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 4f531588ea..7f95b8e203 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -36,7 +36,7 @@ pub fn specs() -> Vec { "Export Document", [], None, - "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin., which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), …hpgl writes a plot of the artboard as pen moves for plotters and cutters (each visible path once along its line in plotter units; useArtboards: true one file per chosen artboard, false the bounds of the visible art), hpglVersion?: hpgl (default: IN, SP, PU, PD) | hpgl2 (also NP and PC pen colors, each pen's paths as one encoded polyline PE), curveTolerance?: 0.1 (points, 0.01 to 10), pens?: [\"#rrggbb\"…] (pen 1 first, up to 255; each path takes the nearest; default a pen per color in the order the art first draws in it, numbered alike in every file), origin?: bottomLeft (default) | center, sortPaths?: false (each pen's next path the nearest), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored. Never changes the document's path", + "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin., which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), …hpgl writes a plot of the artboard as pen moves for plotters and cutters (each visible path once along its line in plotter units; useArtboards: true one file per chosen artboard, false the bounds of the visible art), hpglVersion?: hpgl (default: IN, SP, PU, PD) | hpgl2 (also NP and PC pen colors, each pen's paths as one encoded polyline PE), curveTolerance?: 0.1 (points, 0.01 to 10), pens?: [\"#rrggbb\"…] (pen 1 first, up to 255; each path takes the nearest; default a pen per color in the order the art first draws in it, numbered alike in every file), origin?: bottomLeft (default) | center, sortPaths?: false (each pen's next path the nearest), insideFirst?: false (a path after the pen's paths that lie inside it), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored. Never changes the document's path", has_doc, export::export ), diff --git a/crates/engine/src/cmd/fileio/hpgl.rs b/crates/engine/src/cmd/fileio/hpgl.rs index a7980b5072..ff6e63fbd7 100644 --- a/crates/engine/src/cmd/fileio/hpgl.rs +++ b/crates/engine/src/cmd/fileio/hpgl.rs @@ -48,6 +48,12 @@ pub(super) const OPTIONS: &[FormatOption] = &[ default: "false", description: "draw each pen's next path from the end nearest where the pen stopped, instead of in paint order", }, + FormatOption { + name: "insideFirst", + ty: "boolean", + default: "false", + description: "draw a path only after the pen's paths that lie inside it (a letter's counter before the letter, a hole before the part), the paths otherwise in paint order; with sortPaths, the nearest of the paths free to draw comes next", + }, ]; /// The HP-GL options in `p` (the region is set per plot). @@ -75,6 +81,7 @@ fn parse(p: &Value) -> Result { o.origin = Origin::from_id(s).ok_or_else(|| bad(C, format!("`{key}` must be one of {}", Origin::ALL.map(Origin::id).join(", "))))?; } o.sort = switch_or(p, "sortPaths", false, C)?; + o.inside_first = switch_or(p, "insideFirst", false, C)?; Ok(o) } diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index d010dab3d0..ba05ec19e2 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -885,7 +885,13 @@ impl SaveJob { /// folder?, warnings, files?: [{name, dataBase64}] (every file, when there are several), /// linked?: [{name, dataBase64}]}`; with one → `{path, format, bytes, warnings, files?: /// [path…], linked?: [path…]}`. - pub fn write(&self, mut write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result { + pub fn write(&self, write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result { + self.write_unless(|_| None, write) + } + + /// [`SaveJob::write`], once `stop` has seen the paths of every file it writes to a path, in + /// order: when `stop` returns a value, nothing is written and that value is returned. + pub fn write_unless(&self, stop: impl FnOnce(&[&str]) -> Option, mut write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result { let (plan, doc) = (&self.plan, &*self.doc); let mut enc = self.encode()?; let warnings = std::mem::take(&mut enc.warnings); @@ -899,6 +905,9 @@ impl SaveJob { return Ok(out); }; let files = enc.named(doc, path); + if let Some(stopped) = stop(&files.iter().map(|(p, _)| p.as_str()).collect::>()) { + return Ok(stopped); + } for (p, bytes) in &files { write(p, bytes)?; } diff --git a/crates/engine/src/cmd/fileio/tests_hpgl.rs b/crates/engine/src/cmd/fileio/tests_hpgl.rs index 1cb4c03b83..f4767fd81d 100644 --- a/crates/engine/src/cmd/fileio/tests_hpgl.rs +++ b/crates/engine/src/cmd/fileio/tests_hpgl.rs @@ -102,7 +102,7 @@ fn the_dialogs_and_the_format_list_name_hpgl() { 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(true), Some(false))); - for o in ["useArtboards", "hpglVersion", "curveTolerance", "pens", "origin", "sortPaths"] { + for o in ["useArtboards", "hpglVersion", "curveTolerance", "pens", "origin", "sortPaths", "insideFirst"] { assert!(f["options"].get(o).is_some(), "{o}: {f}"); } assert_eq!(writable_format(None, Some("x/part.plt")).unwrap().id, "hpgl"); @@ -184,6 +184,7 @@ fn export_options_are_checked() { (json!({"format": "hpgl", "pens": "#ff0000"}), "`pens` must be an array of 1 to 255 #rrggbb colors"), (json!({"format": "hpgl", "origin": "top"}), "`origin` must be one of bottomLeft, center"), (json!({"format": "hpgl", "sortPaths": "yes"}), "`sortPaths` must be true or false"), + (json!({"format": "hpgl", "insideFirst": 1}), "`insideFirst` must be true or false"), ]; for (p, want) in cases { let e = refused(&mut s, p.clone()); @@ -198,6 +199,25 @@ fn export_options_are_checked() { assert_eq!(plot, "IN;PU;SP2;PU-141,141;PD-564,141,-564,564,-141,564,-141,141;PU;SP0;"); } +/// `insideFirst` draws the square inside the other one first, though it was made last. +#[test] +fn inside_first_draws_the_inner_square_first() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + s.execute("paint.setFill", &json!({"none": true})).unwrap(); + s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 80, "height": 80})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 30, "y": 30, "width": 20, "height": 20})).unwrap(); + let starts = |s: &mut Session, p: Value| { + let plot = String::from_utf8(bytes(&s.execute("document.export", &p).unwrap())).unwrap(); + (plot.find("PU141,1270;").unwrap(), plot.find("PU423,988;").unwrap()) + }; + let (outer, inner) = starts(&mut s, json!({"format": "hpgl"})); + assert!(outer < inner); + let (outer, inner) = starts(&mut s, json!({"format": "hpgl", "insideFirst": true})); + assert!(inner < outer); +} + /// Export Selection plots the selected square alone (the red one, made last), from the /// bottom-left corner of its bounds. #[test] diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 49b1015bf4..593d325460 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -15,7 +15,7 @@ use vectorcraft_geom::{Affine, Rect}; use super::{ CommandSpec, Result, Session, bad, bool_or, check_numbers, check_switches, checked_id, checked_ids_param, cmd, color_value, f64_or, has_doc, - has_selection, id_param, ids_param, index, ok, str_param, switch_or, + has_selection, ids_param, index, object_param, ok, str_param, switch_or, }; use crate::EngineError; @@ -426,7 +426,7 @@ fn paste_remembers_layers(s: &mut Session, p: &Value) -> Result { /// The Layers panel's target circle. fn target(s: &mut Session, p: &Value) -> Result { const C: &str = "layer.target"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let st = s.doc()?; let n = st.doc.node(id).ok_or(EngineError::NoNode(id))?; if !st.doc.is_editable(id) { @@ -594,7 +594,7 @@ fn duplicate_rows(s: &mut Session, p: &Value) -> Result { fn set_current(s: &mut Session, p: &Value) -> Result { const C: &str = "layer.setCurrent"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let st = s.doc_mut()?; let layer = st.doc.layer_containing(id).ok_or(EngineError::NoNode(id))?; st.active_layer = Some(layer); @@ -700,7 +700,7 @@ fn in_isolation(doc: &Document, isolation: Option, id: NodeId) -> bool { /// The selection column: select a row's art. fn select_art(s: &mut Session, p: &Value) -> Result { const C: &str = "layer.selectAll"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let add = switch_or(p, "add", false, C)?; let st = s.doc()?; let n = st.doc.node(id).ok_or(EngineError::NoNode(id))?; @@ -747,9 +747,12 @@ enum Place { /// Drag rows in the Layers panel. fn move_rows(s: &mut Session, p: &Value) -> Result { const C: &str = "layer.move"; - let mut ids = ids_param(p, "ids", C)?.or_else(|| id_param(p, "id").map(|i| vec![i])).ok_or_else(|| bad(C, "missing ids"))?; + let mut ids = match ids_param(p, "ids", C)? { + Some(ids) => ids, + None => vec![object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing ids"))?], + }; ids.truncate(MAX_ROWS); - let target = id_param(p, "target").ok_or_else(|| bad(C, "missing target"))?; + let target = object_param(p, "target", C)?.ok_or_else(|| bad(C, "missing target"))?; let place = match str_param(p, "place").unwrap_or("inside") { "above" => Place::Above, "below" => Place::Below, @@ -831,8 +834,8 @@ fn move_rows(s: &mut Session, p: &Value) -> Result { fn move_node(s: &mut Session, p: &Value) -> Result { const C: &str = "node.move"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; - let parent = id_param(p, "parent"); + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; + let parent = object_param(p, "parent", C)?; let index = p.get("index").and_then(Value::as_u64).map_or(usize::MAX, index); let d = &s.doc()?.doc; let n = d.node(id).ok_or(EngineError::NoNode(id))?; diff --git a/crates/engine/src/cmd/live.rs b/crates/engine/src/cmd/live.rs index 464f241159..618865cede 100644 --- a/crates/engine/src/cmd/live.rs +++ b/crates/engine/src/cmd/live.rs @@ -21,7 +21,7 @@ use super::expand::gradient_sampler; use super::pathops::Beside; use super::{ CommandSpec, EngineError, Result, Session, always, bad, check_numbers, cmd, color_param, f64_or, f64_req, finite_numbers, has_doc, has_multi, - has_selection, id_param, named_ids, number_or, ok, str_param, switch_or, text_param, + has_selection, named_ids, number_or, object_param, ok, str_param, switch_or, text_param, }; mod blend; diff --git a/crates/engine/src/cmd/live/blend.rs b/crates/engine/src/cmd/live/blend.rs index 609936ecf7..d47e81befd 100644 --- a/crates/engine/src/cmd/live/blend.rs +++ b/crates/engine/src/cmd/live/blend.rs @@ -324,7 +324,11 @@ pub(super) fn spine_remove_anchor(s: &mut Session, p: &Value) -> Result { if anchors.contains(&index) { return Err(bad(C, "a key object sits on that point")); } - if sp.anchors.len() <= 2 { + // A closed spine pinned open keeps a copy of its first point at its end (#6809), so the + // points left must be two apart, not only two in number. + let mut rest = sp.anchors.iter().enumerate().filter(|(i, _)| *i != index).map(|(_, a)| a.p); + let first = rest.next(); + if sp.anchors.len() <= 2 || first.is_none_or(|f| rest.all(|q| q.distance(f) < 0.01)) { return Err(bad(C, "a spine keeps at least two points")); } sp.anchors.remove(index); diff --git a/crates/engine/src/cmd/live/envelope.rs b/crates/engine/src/cmd/live/envelope.rs index 1aceb49b54..3412023280 100644 --- a/crates/engine/src/cmd/live/envelope.rs +++ b/crates/engine/src/cmd/live/envelope.rs @@ -3,7 +3,7 @@ use super::common::{fix_ids, ids_json, is_envelope, roots_param, selected_of, wr use super::mesh::{handle_param, index_param, whole}; use super::{ Affine, Appearance, Arc, Beside, Color, Document, EngineError, EnvelopeKind, EnvelopeMap, EnvelopeOptions, Node, NodeId, NodeKind, Paint, - PathData, Point, PreserveShape, Result, Session, Value, bad, check_numbers, f64_or, f64_req, id_param, json, live, ok, switch_or, text_param, + PathData, Point, PreserveShape, Result, Session, Value, bad, check_numbers, f64_or, f64_req, json, live, object_param, ok, switch_or, text_param, }; // ---------- Envelope ---------- @@ -419,7 +419,7 @@ pub(super) fn env_edit_contents(s: &mut Session, p: &Value) -> Result { pub(super) fn env_set_mesh_point(s: &mut Session, p: &Value) -> Result { const C: &str = "object.envelope.setMeshPoint"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?; let index = index_param(p, "index", C)?; let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?); let handle = handle_param(p, C)?; diff --git a/crates/engine/src/cmd/live/mesh.rs b/crates/engine/src/cmd/live/mesh.rs index 70c2d9f348..187d2149f2 100644 --- a/crates/engine/src/cmd/live/mesh.rs +++ b/crates/engine/src/cmd/live/mesh.rs @@ -4,7 +4,7 @@ use super::common::{fix_ids, ids_json, is_mesh, replace_with, roots_param, selec use super::envelope::{outline_of, rows_cols}; use super::{ Color, Document, EngineError, EnvelopeKind, GradientMesh, MeshAppearance, Node, NodeId, NodeKind, Paint, Point, Result, Session, Value, bad, - check_numbers, color_param, f64_or, f64_req, finite_numbers, gradient_sampler, id_param, json, live, ok, str_param, + check_numbers, color_param, f64_or, f64_req, finite_numbers, gradient_sampler, json, live, object_param, ok, str_param, }; // ---------- Gradient mesh ---------- @@ -115,7 +115,7 @@ fn image_mesh(d: &Document, im: &vectorcraft_doc::ImageObject, rows: u32, cols: /// Run `f` on the gradient mesh `p.id` (one undo step `label`); with `envelopes`, a mesh /// envelope's grid too (see [`edit_grid`]). fn with_mesh(s: &mut Session, p: &Value, cmd: &str, label: &str, envelopes: bool, f: impl FnOnce(&mut GradientMesh) -> Result) -> Result { - let id = id_param(p, "id").ok_or_else(|| bad(cmd, "missing `id`"))?; + let id = object_param(p, "id", cmd)?.ok_or_else(|| bad(cmd, "missing `id`"))?; s.edit(label, move |d, _| edit_grid(d, id, cmd, envelopes, f)) } @@ -147,7 +147,7 @@ const MAX_POINTS: usize = 1 << 20; /// points}…]}`. Each mesh comes once, with its points in order and without repeats. fn point_items(p: &Value, cmd: &str) -> Result)>> { let one = |q: &Value| -> Result<(NodeId, BTreeSet)> { - let id = id_param(q, "id").ok_or_else(|| bad(cmd, "missing `id`"))?; + let id = object_param(q, "id", cmd)?.ok_or_else(|| bad(cmd, "missing `id`"))?; let points = match q.get("points") { None | Some(Value::Null) => BTreeSet::from([index_param(q, "index", cmd)?]), Some(Value::Array(v)) if v.len() <= MAX_POINTS => v diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index b8bdd24d05..83a0655284 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -67,7 +67,7 @@ pub fn specs() -> Vec { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; graph labels keep it for every label of their kind when the graph is drawn again; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left, and on a vertical path toward or away from the path's start, to its left or right), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text, and graph labels keep the character and paragraph attributes for every label of their kind when the graph is drawn again, the graph still setting each label's alignment)", + "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; graph labels keep it for every label of their kind when the graph is drawn again; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left, and on a vertical path toward or away from the path's start, to its left or right), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), koreanLineBreakAnywhere?: bool (Paragraph panel menu › Korean Line Break Anywhere: a line may break between any two Hangul syllables; off, a word with Hangul in it breaks only at its spaces and punctuation, or where it is wider than the line; new type: false, paragraphs saved before the option: true), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text, and graph labels keep the character and paragraph attributes for every label of their kind when the graph is drawn again, the graph still setting each label's alignment)", has_doc, set_format ), @@ -482,6 +482,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { "hyphenate", "hyphenateCapitalized", "romanHangingPunctuation", + "koreanLineBreakAnywhere", ], C, )?; @@ -552,6 +553,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { "fontAxes", "burasagari", "romanHangingPunctuation", + "koreanLineBreakAnywhere", "kashida", "composer", ]; @@ -879,6 +881,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result { if let Some(v) = flag("romanHangingPunctuation") { para.roman_hanging = v; } + if let Some(v) = flag("koreanLineBreakAnywhere") { + para.korean_line_break_anywhere = v; + } if let Some(v) = kashida { para.kashida = v; } diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index 91da35e51b..c05493e964 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -12,7 +12,7 @@ use super::draw2::usize_opt; use super::pathops::Beside; use super::{ CommandSpec, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, f64_req, finite_numbers, has_anchors, has_doc, has_selected_path, - has_selection, id_param, ids_param, index, number_or, ok, str_param, switch_or, targets, + has_selection, ids_param, index, number_or, object_param, ok, str_param, switch_or, targets, }; use crate::EngineError; @@ -168,7 +168,7 @@ pub(super) fn path_mut(d: &mut vectorcraft_doc::Document, id: NodeId) -> Result< } fn append_anchor(s: &mut Session, p: &Value) -> Result { - let id = id_param(p, "id").ok_or_else(|| bad("path.appendAnchor", "missing id"))?; + let id = object_param(p, "id", "path.appendAnchor")?.ok_or_else(|| bad("path.appendAnchor", "missing id"))?; let a = anchor_from_json(p, "path.appendAnchor")?; let subpath = p.get("subpath").and_then(Value::as_u64).map(|u| u as usize); s.edit("Pen", |d, sel| { @@ -206,7 +206,7 @@ fn append_anchor(s: &mut Session, p: &Value) -> Result { /// incoming handle moves as Move Direction Handle moves it: the outgoing handle turns with it only /// on a smooth anchor, so the first segment of a path that starts at a corner keeps its shape. fn close(s: &mut Session, p: &Value) -> Result { - let id = id_param(p, "id").ok_or_else(|| bad("path.close", "missing id"))?; + let id = object_param(p, "id", "path.close")?.ok_or_else(|| bad("path.close", "missing id"))?; let subpath = p.get("subpath").and_then(Value::as_u64).map(|u| u as usize); let handle = |key: &str| match p.get(key).filter(|h| !h.is_null()) { None => Ok(None), @@ -303,7 +303,7 @@ pub(crate) fn transform_anchors(s: &mut Session, label: &str, xf: vectorcraft_ge } fn set_handle(s: &mut Session, p: &Value) -> Result { - let id = id_param(p, "id").ok_or_else(|| bad("path.setHandle", "missing id"))?; + let id = object_param(p, "id", "path.setHandle")?.ok_or_else(|| bad("path.setHandle", "missing id"))?; let si = usize_opt(p, "subpath", "path.setHandle")?.unwrap_or(0); let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.setHandle", "missing anchor"))?; let out = str_param(p, "which") != Some("in"); @@ -321,7 +321,7 @@ fn set_handle(s: &mut Session, p: &Value) -> Result { /// `path.moveAnchor`: one anchor to (x, y), its handles keeping their offsets (the Anchor Point /// tool's Shift-drag). fn move_anchor(s: &mut Session, p: &Value) -> Result { - let id = id_param(p, "id").ok_or_else(|| bad("path.moveAnchor", "missing id"))?; + let id = object_param(p, "id", "path.moveAnchor")?.ok_or_else(|| bad("path.moveAnchor", "missing id"))?; let si = usize_opt(p, "subpath", "path.moveAnchor")?.unwrap_or(0); let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.moveAnchor", "missing anchor"))?; let pos = Point::new(f64_req(p, "x", "path.moveAnchor")?, f64_req(p, "y", "path.moveAnchor")?); @@ -341,7 +341,7 @@ fn move_anchor(s: &mut Session, p: &Value) -> Result { } fn set_anchors(s: &mut Session, p: &Value) -> Result { - let id = id_param(p, "id").ok_or_else(|| bad("path.setAnchors", "missing id"))?; + let id = object_param(p, "id", "path.setAnchors")?.ok_or_else(|| bad("path.setAnchors", "missing id"))?; let subs = p.get("subpaths").and_then(Value::as_array).ok_or_else(|| bad("path.setAnchors", "missing subpaths"))?; let data = PathData::new( subs.iter() @@ -1562,7 +1562,7 @@ fn renumber_after_insert(sel: &mut Selection, id: NodeId, si: usize, added: usiz } fn reshape(s: &mut Session, p: &Value) -> Result { - let id = id_param(p, "id").ok_or_else(|| bad("path.reshape", "missing id"))?; + let id = object_param(p, "id", "path.reshape")?.ok_or_else(|| bad("path.reshape", "missing id"))?; let at = Point::new(f64_req(p, "x", "path.reshape")?, f64_req(p, "y", "path.reshape")?); check_numbers(p, &["dx", "dy", "tol", "radius"], "path.reshape")?; let delta = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0)); @@ -1684,7 +1684,7 @@ fn reshape(s: &mut Session, p: &Value) -> Result { } fn insert_anchor(s: &mut Session, p: &Value) -> Result { - let id = id_param(p, "id").ok_or_else(|| bad("path.insertAnchor", "missing id"))?; + let id = object_param(p, "id", "path.insertAnchor")?.ok_or_else(|| bad("path.insertAnchor", "missing id"))?; let si = usize_opt(p, "subpath", "path.insertAnchor")?.unwrap_or(0); let seg = p.get("segment").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.insertAnchor", "missing segment"))?; let t = number_or(p, "t", 0.5, "path.insertAnchor")?.clamp(0.0, 1.0); diff --git a/crates/engine/src/cmd/slices.rs b/crates/engine/src/cmd/slices.rs index 29a357f730..746be9c244 100644 --- a/crates/engine/src/cmd/slices.rs +++ b/crates/engine/src/cmd/slices.rs @@ -13,8 +13,8 @@ use vectorcraft_doc::{Appearance, CellAlign, CellVAlign, Document, Node, NodeKin use vectorcraft_geom::{Rect, shapes}; use super::{ - CommandSpec, EngineError, NodeId, Result, Session, bad, cmd, f64_req, has_doc, has_selection, id_param, ids_param, index, number_or, switch_or, - targets, + CommandSpec, EngineError, NodeId, Result, Session, bad, cmd, f64_req, has_doc, has_selection, ids_param, index, number_or, object_param, + switch_or, targets, }; /// The most slices one Divide Slices makes. @@ -680,7 +680,7 @@ fn create(s: &mut Session, p: &Value) -> Result { fn set_rect(s: &mut Session, p: &Value) -> Result { const C: &str = "object.slice.setRect"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?; let r = rect_param(p, C)?; let d = &s.doc()?.doc; if d.slice(id).is_none() { diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index c09ca08164..edfab850f5 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -11,8 +11,8 @@ use super::menucmds::whole_or; use super::stroke::StrokeChange; use super::typecmd::refresh_bounds; use super::{ - CommandSpec, EngineError, Result, Session, bad, bool_or, check_numbers, check_switches, cmd, color_value, has_doc, has_selection, id_param, - index, number_or, point_param, str_param, switch_or, targets, text_param, + CommandSpec, EngineError, Result, Session, bad, bool_or, check_numbers, check_switches, cmd, color_value, has_doc, has_selection, index, + number_or, object_param, point_param, str_param, switch_or, targets, text_param, }; pub fn specs() -> Vec { @@ -272,7 +272,7 @@ fn range_of(p: &Value, len: usize, cmd: &str) -> Result<(usize, usize)> { fn edit_range(s: &mut Session, p: &Value) -> Result { const C: &str = "text.editRange"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?; let t = text_ref(s, id)?; let len = edit::runs_len(&t.runs); let (a, b) = range_of(p, len, C)?; @@ -337,7 +337,7 @@ fn clear_param(s: &Session, p: &Value, id: NodeId, c: &str) -> Result Result { const C: &str = "text.startList"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?; let t = text_ref(s, id)?; let len = edit::runs_len(&t.runs); let at = offset_or(p, "at", len, C)?.min(len); @@ -365,7 +365,7 @@ fn start_list(s: &mut Session, p: &Value) -> Result { } fn get_range(s: &mut Session, p: &Value) -> Result { - let id = id_param(p, "id").ok_or_else(|| bad("text.getRange", "missing `id`"))?; + let id = object_param(p, "id", "text.getRange")?.ok_or_else(|| bad("text.getRange", "missing `id`"))?; if switch_or(p, "story", false, "text.getRange")? { return get_story(s, p, id); } @@ -1261,7 +1261,7 @@ fn drawn_glyph( fn set_glyph_alternate(s: &mut Session, p: &Value) -> Result { const C: &str = "text.setGlyphAlternate"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?; let gid = match p.get("gid") { None => return Err(bad(C, "missing `gid` (a glyph id, or null for the default glyph)")), Some(Value::Null) => None, @@ -1351,7 +1351,7 @@ fn set_glyph_alternate(s: &mut Session, p: &Value) -> Result { fn glyph_alternates(s: &mut Session, p: &Value) -> Result { const C: &str = "text.glyphAlternates"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?; let t = text_ref(s, id)?; let (ch, bytes, face) = glyph_target(&t, p, C)?; let default = face.glyph_for(ch); @@ -1366,7 +1366,7 @@ fn glyph_alternates(s: &mut Session, p: &Value) -> Result { fn touch_type(s: &mut Session, p: &Value) -> Result { const C: &str = "text.touchType"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?; check_numbers(p, &["start"], C)?; let start = byte_param(p, "start").ok_or_else(|| bad(C, "missing `start` (the byte offset of a character)"))?; if !["dx", "dy", "hScale", "vScale", "rotation"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) { @@ -1592,7 +1592,7 @@ fn touch_type(s: &mut Session, p: &Value) -> Result { fn create_in_path(s: &mut Session, p: &Value) -> Result { const C: &str = "text.createInPath"; - let pid = id_param(p, "path").ok_or_else(|| bad(C, "missing `path` (a path object id)"))?; + let pid = object_param(p, "path", C)?.ok_or_else(|| bad(C, "missing `path` (a path object id)"))?; let on_path = match str_param(p, "mode").unwrap_or("area") { "area" => false, "onPath" | "path" => true, @@ -1996,7 +1996,7 @@ fn fit_headline(s: &mut Session, p: &Value) -> Result { fn discard_empty(s: &mut Session, p: &Value) -> Result { const C: &str = "text.discardEmpty"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?; if edit::runs_len(&text_ref(s, id)?.runs) > 0 { return Ok(json!({"removed": false})); } diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index d94cfa870e..40d8af22ad 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -245,6 +245,7 @@ pub fn para_attrs(v: &ParaStyle) -> Map { ("kinsoku_type", json!(v.kinsoku_type)), ("roman_hanging", json!(v.roman_hanging)), ("kashida", json!(v.kashida)), + ("korean_line_break_anywhere", json!(v.korean_line_break_anywhere)), ] { m.insert(k.into(), x); } diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 33dad39d93..06368c6c7d 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -10,8 +10,8 @@ use vectorcraft_geom::{Affine, PathData, Vec2}; use super::edit::selected_roots; use super::pathops::{length_given, number_given, shape_node}; use super::{ - CommandSpec, EngineError, Result, Session, bad, check_numbers, cmd, color_value, f64_req, has_doc, has_selection, id_param, index, number_or, ok, - str_param, switch_or, targets, text_param, + CommandSpec, EngineError, Result, Session, bad, check_numbers, cmd, color_value, f64_req, has_doc, has_selection, index, number_or, object_param, + ok, str_param, switch_or, targets, text_param, }; pub fn specs() -> Vec { @@ -1273,7 +1273,7 @@ fn reshape_area(s: &mut Session, p: &Value) -> Result { }; let mut items = Vec::with_capacity(raw.len()); for it in raw { - let id = id_param(it, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(it, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let mut refs = super::select::checked_refs(it.get("anchors"), C)?; refs.sort_unstable(); refs.dedup(); @@ -1304,7 +1304,7 @@ fn reshape_area(s: &mut Session, p: &Value) -> Result { fn reshape_path(s: &mut Session, p: &Value) -> Result { const C: &str = "text.reshapePath"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let mut refs = super::select::checked_refs(p.get("anchors"), C)?; refs.sort_unstable(); refs.dedup(); @@ -1335,7 +1335,7 @@ fn reshape_path(s: &mut Session, p: &Value) -> Result { fn set_path_handle(s: &mut Session, p: &Value) -> Result { const C: &str = "text.setPathHandle"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let si = super::draw2::usize_opt(p, "subpath", C)?.unwrap_or(0); let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad(C, "missing anchor"))?; let out = super::object::choice(p, C, "which", &["in", "out"])? != Some("in"); diff --git a/crates/engine/src/cmd/typecmd/area_tests.rs b/crates/engine/src/cmd/typecmd/area_tests.rs index 571c9c3469..5545e1be94 100644 --- a/crates/engine/src/cmd/typecmd/area_tests.rs +++ b/crates/engine/src/cmd/typecmd/area_tests.rs @@ -397,6 +397,49 @@ fn roman_hanging_punctuation_is_set_per_paragraph_and_saved_only_when_on() { assert!(text(&s).para_at(1).roman_hanging); } +/// Paragraph panel menu › Korean Line Break Anywhere: new type has it off and saves that; +/// `text.setFormat {koreanLineBreakAnywhere}` sets it per paragraph in one undo step and refuses a +/// value that is not true or false, a paragraph saved before the option opens with it on, and +/// paragraph styles carry it. `text.setRangeStyle`, a character command, does not take it. +#[test] +fn korean_line_break_anywhere_is_set_per_paragraph_and_saved_only_when_off() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 10, "size": 20, "text": "가나다\n라마"})).unwrap()["id"].as_u64().unwrap(); + let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("text"), + }; + assert!(!text(&s).para_at(0).korean_line_break_anywhere, "new type has it off"); + assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["korean_line_break_anywhere"], false); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + let steps = s.doc().unwrap().history.undo.len(); + // Only the first paragraph (the caret's range). + s.execute("text.setFormat", &json!({"koreanLineBreakAnywhere": true, "start": 0, "end": 0})).unwrap(); + assert!(text(&s).para_at(0).korean_line_break_anywhere && !text(&s).para_at(1).korean_line_break_anywhere); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1); + assert!(serde_json::to_value(text(&s).para_at(0)).unwrap().get("korean_line_break_anywhere").is_none()); + let back: vectorcraft_doc::ParaStyle = serde_json::from_value(serde_json::to_value(text(&s).para_at(0)).unwrap()).unwrap(); + assert!(back.korean_line_break_anywhere); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!text(&s).para_at(0).korean_line_break_anywhere); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(text(&s).para_at(0).korean_line_break_anywhere); + // Saved before the option: on, the line breaks it was laid out with. + let old: vectorcraft_doc::ParaStyle = serde_json::from_value(json!({"justify": "Left"})).unwrap(); + assert!(old.korean_line_break_anywhere); + assert!(s.execute("text.setFormat", &json!({"ids": [id], "koreanLineBreakAnywhere": "yes"})).is_err()); + assert!(s.execute("text.setRangeStyle", &json!({"id": id, "koreanLineBreakAnywhere": false})).is_err()); + assert!(text(&s).para_at(0).korean_line_break_anywhere, "a refused call changes nothing"); + // A paragraph style carries it. + s.execute("paraStyle.new", &json!({"name": "Anywhere", "attrs": {"korean_line_break_anywhere": true}})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Anywhere", "id": id})).unwrap(); + assert!(text(&s).para_at(1).korean_line_break_anywhere); + s.execute("paraStyle.new", &json!({"name": "Words", "attrs": {"korean_line_break_anywhere": false}})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Words", "id": id})).unwrap(); + assert!(!text(&s).para_at(0).korean_line_break_anywhere && !text(&s).para_at(1).korean_line_break_anywhere); +} + /// Paragraph panel › Insert Kashidas: `text.setFormat {kashida}` sets it per paragraph in one /// undo step, the paragraph is saved with it only when it is not None and opens with it, an /// unknown value changes nothing, and paragraph styles carry it. diff --git a/crates/engine/src/cmd/wand.rs b/crates/engine/src/cmd/wand.rs index c79ec0b86e..218871972f 100644 --- a/crates/engine/src/cmd/wand.rs +++ b/crates/engine/src/cmd/wand.rs @@ -8,7 +8,7 @@ use vectorcraft_color::Paint; use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; use super::select::selectable_ids; -use super::{CommandSpec, EngineError, Result, Session, always, bad, bool_or, cmd, has_doc, id_param, str_param}; +use super::{CommandSpec, EngineError, Result, Session, always, bad, bool_or, cmd, has_doc, object_param, str_param}; /// Magic Wand panel settings. Tolerances: colours as 0–255 RGB distance, stroke weight in /// points, opacity in percent. @@ -152,7 +152,7 @@ fn paintable(doc: &Document) -> Vec { } fn magic_wand(s: &mut Session, p: &Value) -> Result { - let id = id_param(p, "id").ok_or_else(|| bad("select.magicWand", "missing id"))?; + let id = object_param(p, "id", "select.magicWand")?.ok_or_else(|| bad("select.magicWand", "missing id"))?; let st = s.doc()?; let reference = st.doc.node(id).ok_or(EngineError::NoNode(id))?.clone(); let w = s.menu.wand.clone(); diff --git a/crates/engine/src/tests/live.rs b/crates/engine/src/tests/live.rs index 9fc790efa2..0cc01bdae5 100644 --- a/crates/engine/src/tests/live.rs +++ b/crates/engine/src/tests/live.rs @@ -424,6 +424,93 @@ fn blend_replace_spine_follows_path() { assert!(first.distance(Point::new(100.0, 300.0)) < 1.0, "{first:?}"); } +/// #6809: after Replace Spine, moving a key object moves the drawn key and its end of the spine, +/// not only the key's selection outline. +#[test] +fn moving_a_key_of_a_replaced_spine_blend_moves_what_is_drawn() { + let mut s = session(); + let (a, _, g) = blend(&mut s, json!({"steps": 5})); + let arc = id_of(&s.execute("path.create", &json!({"d": "M100 300 C100 245 145 200 200 200 C255 200 300 245 300 300"})).unwrap()); + sel(&mut s, &[g, arc]); + s.execute("object.blend.replaceSpine", &json!({})).unwrap(); + let center = |n: &Node| n.geometric_bounds().unwrap().center(); + let before = steps_of(&s, g); + assert!(center(&before[0]).distance(Point::new(100.0, 300.0)) < 1e-6); + // Direct Selection picks the key inside the blend; the drag moves it. + sel(&mut s, &[a]); + s.execute("object.move", &json!({"dx": -50, "dy": 40})).unwrap(); + let key = center(&node(&s, a)); + assert!(key.distance(Point::new(50.0, 340.0)) < 1e-6, "{key:?}"); + let after = steps_of(&s, g); + assert_eq!(after.len(), before.len()); + let drawn = center(&after[0]); + assert!(drawn.distance(key) < 1e-6, "the key is drawn where it was moved to: {drawn:?}, outline at {key:?}"); + assert!(center(&after[1]).distance(center(&before[1])) > 1.0, "the first step follows the moved key"); + let last = center(&after[after.len() - 1]); + assert!(last.distance(Point::new(300.0, 300.0)) < 1e-6, "the other key stays put: {last:?}"); + // Undo puts the key back on the spine as Replace Spine left it. + s.execute("edit.undo", &json!({})).unwrap(); + let back = steps_of(&s, g); + assert!(center(&back[0]).distance(Point::new(100.0, 300.0)) < 1e-6); + assert!(center(&back[1]).distance(center(&before[1])) < 1e-6); +} + +/// #6809 on a closed spine, where both end keys start on its first point: moving the first key +/// moves its end of the spine without dragging the last key's end along. +#[test] +fn moving_the_first_key_of_a_closed_replaced_spine_blend_moves_what_is_drawn() { + let mut s = session(); + let (a, _, g) = blend(&mut s, json!({"steps": 5})); + let square = id_of(&s.execute("path.create", &json!({"d": "M100 100 L300 100 L300 300 L100 300 Z"})).unwrap()); + sel(&mut s, &[g, square]); + s.execute("object.blend.replaceSpine", &json!({})).unwrap(); + let center = |n: &Node| n.geometric_bounds().unwrap().center(); + let before = steps_of(&s, g); + assert!(center(&before[0]).distance(Point::new(100.0, 100.0)) < 1e-6); + sel(&mut s, &[a]); + s.execute("object.move", &json!({"dx": -50, "dy": -40})).unwrap(); + let key = center(&node(&s, a)); + assert!(key.distance(Point::new(50.0, 60.0)) < 1e-6, "{key:?}"); + let after = steps_of(&s, g); + assert_eq!(after.len(), before.len()); + let drawn = center(&after[0]); + assert!(drawn.distance(key) < 1e-6, "the key is drawn where it was moved to: {drawn:?}, outline at {key:?}"); + assert!(center(&after[1]).distance(center(&before[1])) > 1.0, "the first step follows the moved key"); + let last = center(&after[after.len() - 1]); + assert!(last.distance(Point::new(100.0, 100.0)) < 1e-6, "the other key stays put: {last:?}"); + s.execute("edit.undo", &json!({})).unwrap(); + let back = steps_of(&s, g); + assert!(center(&back[0]).distance(Point::new(100.0, 100.0)) < 1e-6); + assert!(center(&back[1]).distance(center(&before[1])) < 1e-6); +} + +/// #6809 on a closed spine pinned by a spine edit: the end keys get points of their own, so +/// moving the first key afterwards still moves only its end. +#[test] +fn moving_the_first_key_of_a_pinned_closed_spine_blend_moves_what_is_drawn() { + let mut s = session(); + let (a, _, g) = blend(&mut s, json!({"steps": 5})); + let square = id_of(&s.execute("path.create", &json!({"d": "M100 100 L300 100 L300 300 L100 300 Z"})).unwrap()); + sel(&mut s, &[g, square]); + s.execute("object.blend.replaceSpine", &json!({})).unwrap(); + // Dragging a corner away from the keys pins the spine to them. + s.execute("object.blend.spine.moveAnchor", &json!({"id": g.0, "anchor": 2, "x": 320, "y": 320})).unwrap(); + let NodeKind::Blend { spec, .. } = node(&s, g).kind else { panic!() }; + assert_eq!(spec.key_anchors.len(), 2, "{:?}", spec.key_anchors); + let center = |n: &Node| n.geometric_bounds().unwrap().center(); + let before = steps_of(&s, g); + sel(&mut s, &[a]); + s.execute("object.move", &json!({"dx": -50, "dy": -40})).unwrap(); + let key = center(&node(&s, a)); + assert!(key.distance(Point::new(50.0, 60.0)) < 1e-6, "{key:?}"); + let after = steps_of(&s, g); + assert_eq!(after.len(), before.len()); + let drawn = center(&after[0]); + assert!(drawn.distance(key) < 1e-6, "the key is drawn where it was moved to: {drawn:?}, outline at {key:?}"); + let last = center(&after[after.len() - 1]); + assert!(last.distance(Point::new(100.0, 100.0)) < 1e-6, "the other key stays put: {last:?}"); +} + #[test] fn blend_align_to_path_rotates_steps() { let mut s = session(); diff --git a/crates/engine/src/tests/params.rs b/crates/engine/src/tests/params.rs index b0345db4ff..6850715b0e 100644 --- a/crates/engine/src/tests/params.rs +++ b/crates/engine/src/tests/params.rs @@ -400,6 +400,90 @@ fn stroke_set_refuses_an_unknown_key() { } } +/// An object id given as something other than a whole number (`"5"`, `-1`) is refused as such, +/// naming its key, not as a missing id; an id left out is still a missing one. +#[test] +fn a_malformed_object_id_is_not_reported_as_missing() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap(); + let undo = |s: &Session| s.doc().unwrap().history.undo.len(); + let before = undo(&s); + let points = json!([[0, 0], [10, 10], [20, 0]]); + for bad in [json!("5"), json!(-1)] { + let id = || json!({ "id": bad }); + let with = |extra: serde_json::Value| { + let mut p = id(); + p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + p + }; + for (cmd, p, key) in [ + ("stroke.widthPoint.set", id(), "id"), + ("stroke.widthPoint.copy", id(), "id"), + ("stroke.widthPoint.remove", id(), "id"), + ("path.curvatureEdit", id(), "id"), + ("path.removeAnchor", id(), "id"), + ("path.convertAnchor", id(), "id"), + ("path.reshapeSegment", id(), "id"), + ("path.split", id(), "id"), + ("path.appendAnchor", id(), "id"), + ("path.close", id(), "id"), + ("path.setHandle", id(), "id"), + ("path.moveAnchor", id(), "id"), + ("path.setAnchors", id(), "id"), + ("path.reshape", id(), "id"), + ("path.insertAnchor", id(), "id"), + ("path.freehand", json!({"points": points, "extend": {"id": bad}}), "id"), + ("path.freehand", json!({"points": points, "redraw": {"id": bad}}), "id"), + ("document.node", id(), "id"), + ("layer.target", id(), "id"), + ("layer.setCurrent", id(), "id"), + ("layer.selectAll", id(), "id"), + ("layer.move", with(json!({"target": 1})), "id"), + ("layer.move", json!({"ids": [1], "target": bad}), "target"), + ("node.move", id(), "id"), + ("node.move", json!({"id": 1, "parent": bad}), "parent"), + ("text.reshapeArea", with(json!({"anchors": []})), "id"), + ("text.reshapeArea", json!({"items": [{"id": bad, "anchors": []}]}), "id"), + ("text.reshapePath", id(), "id"), + ("text.setPathHandle", id(), "id"), + ("text.editRange", id(), "id"), + ("text.startList", id(), "id"), + ("text.getRange", id(), "id"), + ("text.setGlyphAlternate", id(), "id"), + ("text.glyphAlternates", id(), "id"), + ("text.touchType", id(), "id"), + ("text.discardEmpty", id(), "id"), + ("text.createInPath", json!({"path": bad}), "path"), + ("object.envelope.setMeshPoint", id(), "id"), + ("object.mesh.setPointColor", with(json!({"index": 0})), "id"), + ("object.mesh.movePoint", with(json!({"index": 0, "x": 0, "y": 0})), "id"), + ("object.mesh.movePoints", json!({"items": [{"id": bad, "index": 0}]}), "id"), + ("object.mesh.addLine", with(json!({"x": 0, "y": 0})), "id"), + ("object.mesh.deletePoint", with(json!({"index": 0})), "id"), + ("object.slice.setRect", id(), "id"), + ("appearance.transfer", json!({"source": bad, "target": 1}), "source"), + ("appearance.transfer", json!({"source": 1, "target": bad}), "target"), + ("appearance.copyFrom", json!({"source": bad}), "source"), + ("livePaint.strokeEdge", json!({"point": [0, 0], "group": bad}), "group"), + ("livePaint.fill", json!({"point": [0, 0], "group": bad}), "group"), + ("livePaint.info", json!({"group": bad}), "group"), + ("livePaint.info", id(), "id"), + ("select.magicWand", id(), "id"), + ] { + let e = s.execute(cmd, &p).unwrap_err().to_string(); + // `layer.move` checks an `id` that stands in for `ids` before it runs. + let named = e.contains(&format!("`{key}` must be a whole number")) || (cmd == "layer.move" && e.contains("invalid object id")); + assert!(named, "{cmd} {p}: {e}"); + } + } + for cmd in ["path.close", "layer.target", "text.reshapePath", "object.mesh.movePoint", "select.magicWand"] { + let e = s.execute(cmd, &json!({})).unwrap_err().to_string(); + assert!(e.contains("missing"), "{cmd}: {e}"); + } + assert_eq!(undo(&s), before); +} + /// A command that takes the keys of one that isn't checked yet isn't checked either. #[test] fn a_reference_to_an_unchecked_command_is_unchecked() { diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index 243d6d0081..abb39c0d96 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -475,6 +475,7 @@ fn type_commands_refuse_a_number_or_boolean_given_as_text() { ("text.setFormat", json!({"ids": [id.0], "hyphenate": "false"}), "hyphenate"), ("text.setFormat", json!({"ids": [id.0], "hyphenateCapitalized": 0}), "hyphenateCapitalized"), ("text.setFormat", json!({"ids": [id.0], "romanHangingPunctuation": "true"}), "romanHangingPunctuation"), + ("text.setFormat", json!({"ids": [id.0], "koreanLineBreakAnywhere": 1}), "koreanLineBreakAnywhere"), ("text.getRange", json!({"id": id.0, "effective": "true"}), "effective"), ("type.opticalMarginAlignment", json!({"on": "true"}), "on"), ("type.systemLayout", json!({"on": "true"}), "on"), diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 56bcf2892c..4939327c9d 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -3785,6 +3785,34 @@ fn an_unstroked_run_with_a_stroke_style_loses_nothing() { } } +/// The text document has no Korean Line Break Anywhere: a paragraph with it on and Hangul in it +/// is reported as left out, and comes back off; with it off, or with no Hangul, nothing is +/// reported. +#[test] +fn korean_line_break_anywhere_is_reported_only_for_korean_paragraphs() { + use vectorcraft_doc::TextObject; + for (text, on, reported) in [("가나다 라마", true, true), ("가나다 라마", false, false), ("Plain", true, false)] { + let mut t = TextObject::point(vectorcraft_geom::Point::new(20.0, 40.0), text, style(14.0, Color::BLACK)); + t.para.korean_line_break_anywhere = on; + let mut doc = Document::new(200.0, 200.0); + let layer = doc.layers[0].id; + let n = text_node(&mut doc, t); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert_eq!(!out.warnings.is_empty(), reported, "{text} {on}: {:?}", out.warnings); + let mut all = vec![]; + paths(&read_back(&doc).layers, &mut all); + let paras: Vec = all + .iter() + .filter_map(|n| match &n.kind { + NodeKind::Text(t) => Some(t.para_at(0).korean_line_break_anywhere), + _ => None, + }) + .collect(); + assert_eq!(paras, [false], "{text} {on}"); + } +} + #[test] fn type_is_written_again_as_the_same_bytes() { let doc = typed(); diff --git a/crates/eps/src/ai_editing/writer/texts.rs b/crates/eps/src/ai_editing/writer/texts.rs index 5ebd73de3a..1d791afadf 100644 --- a/crates/eps/src/ai_editing/writer/texts.rs +++ b/crates/eps/src/ai_editing/writer/texts.rs @@ -385,6 +385,8 @@ pub(in crate::ai_editing) fn loses(t: &TextObject) -> bool { if p.justify == Justify::Auto && p.direction.is_none() && rtl_letter { return true; } + let hangul = + text.chars().any(|c| matches!(c, '\u{1100}'..='\u{11FF}' | '\u{3130}'..='\u{318F}' | '\u{A960}'..='\u{A97F}' | '\u{AC00}'..='\u{D7FF}' | '\u{FFA0}'..='\u{FFDC}')); let held = ParaStyle { justify: p.justify, left_indent: p.left_indent, @@ -398,6 +400,9 @@ pub(in crate::ai_editing) fn loses(t: &TextObject) -> bool { // Width, Standard burasagari) isn't reported: it changes no other script's lines. mojikumi: if p.mojikumi == Mojikumi::LineEndHalf { p.mojikumi } else { ParaStyle::default().mojikumi }, burasagari: if p.burasagari == Burasagari::Standard { p.burasagari } else { ParaStyle::default().burasagari }, + // The text document has no Korean Line Break Anywhere: it reads back off, which + // changes only the lines of a paragraph with Hangul in it. + korean_line_break_anywhere: p.korean_line_break_anywhere && !hangul, ..ParaStyle::default() }; *p != held diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index c68a4af43a..6e02df73cd 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -687,6 +687,7 @@ mod emit_kashida_mark_last { tcy: None, inline: None, word_end: false, + keeps_word: false, lead: 0.0, proportional: false, vpal: None, @@ -748,6 +749,7 @@ mod emit_kashida_mark_last { tcy: None, inline: None, word_end: false, + keeps_word: false, lead: 0.0, proportional: false, vpal: None, diff --git a/crates/text/src/layout/breaking.rs b/crates/text/src/layout/breaking.rs index a259ce0881..0f033d4fcb 100644 --- a/crates/text/src/layout/breaking.rs +++ b/crates/text/src/layout/breaking.rs @@ -734,8 +734,20 @@ fn soft_kinsoku_lets_small_kana_and_the_prolonged_sound_mark_start_a_line() { } /// Glyph indices inside `g[ws..we]` (a word) where a hyphenated break may go under `opts`, in the -/// language of the word's first letter. +/// language of the word's first letter; never next to a Hangul letter, which takes no hyphen, and +/// nowhere in a Korean word that Korean Line Break Anywhere off keeps whole. fn hyphen_breaks(text: &str, g: &[SGlyph], ws: usize, we: usize, opts: &HyphenationOptions) -> Vec { + if g.get(ws..we).is_some_and(|word| word.iter().any(|h| h.keeps_word)) { + return Vec::new(); + } + let mut points = word_hyphen_breaks(text, g, ws, we, opts); + let hangul = |i: usize| g.get(i).is_some_and(|h| h.inline.is_none() && crate::shape::is_hangul(h.ch)); + points.retain(|&k| !hangul(k.wrapping_sub(1)) && !hangul(k)); + points +} + +/// [`hyphen_breaks`] before the points next to Hangul are left out. +fn word_hyphen_breaks(text: &str, g: &[SGlyph], ws: usize, we: usize, opts: &HyphenationOptions) -> Vec { // Strip leading/trailing punctuation (quotes, commas). let (mut a, mut b) = (ws, we); while a < b && !g[a].is_letter() { diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index 0fd3c1275d..d7ac206424 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -10,6 +10,7 @@ use super::{ ListMarker, Mojikumi, ParaStyle, Params, Point, Punct, Range, SGlyph, SingleWordJustify, TextObject, apply_desired, cap_x_heights, compose, fit_line, hyphen_glyph, is_cjk, is_word_space, kashida, mark_word_ends, no_line_end, no_line_start, style_metrics, }; +use crate::shape::keep_korean_words; /// Does paragraph `para` push characters in (Kinsoku Shori Type Push In First or Push Out First, /// with kinsoku on)? @@ -224,6 +225,10 @@ fn prepare_paragraph<'a, 'text>( let mut sg = cx.shape_para(pr.clone(), bidi.as_ref()); // Thai, Lao, Khmer and Myanmar break lines between words, not at spaces. mark_word_ends(text.get(pr.clone()).unwrap_or_default(), pr.start, &mut sg); + // Korean breaks between words, unless Korean Line Break Anywhere lets it break between syllables. + if !para.korean_line_break_anywhere { + keep_korean_words(&mut sg); + } // Japanese composition: consecutive punctuation shares one half-em space. let mut compressed = if para.mojikumi == Mojikumi::LineEndHalf { compress_punctuation(&mut sg) } else { vec![] }; // …and a quarter em between Japanese and Latin text. diff --git a/crates/text/src/layout/wrapping_tests.rs b/crates/text/src/layout/wrapping_tests.rs index e077707a4b..0c8ffb4e2f 100644 --- a/crates/text/src/layout/wrapping_tests.rs +++ b/crates/text/src/layout/wrapping_tests.rs @@ -33,6 +33,7 @@ fn synthetic_glyphs(specs: &[(usize, usize, f64, char)]) -> Vec { tcy: None, inline: None, word_end: false, + keeps_word: false, lead: 0.0, proportional: false, vpal: None, @@ -513,3 +514,101 @@ fn composition_counts_the_narrower_line_end() { assert_eq!(lines(38.0, &half, &hyphenated), None); assert_eq!(lines(38.0, &half, &[Breakpoint { end: 4, hyphen: 0.0 }]), Some(vec![(4, false), (7, false)])); } + +/// The glyphs of `text`, each 10 pt wide, with Korean words kept whole unless `anywhere` (Korean +/// Line Break Anywhere on). +fn korean(text: &str, anywhere: bool) -> Vec { + let mut glyphs = full_width(text); + if !anywhere { + crate::shape::keep_korean_words(&mut glyphs); + } + glyphs +} + +/// Korean Line Break Anywhere off: both composers break a Korean word only at its spaces, so a +/// word that doesn't fit moves to the next line whole; on, they break between its syllables. +#[test] +fn korean_words_break_at_spaces_unless_line_break_anywhere() { + use KinsokuShoriType::PushOutOnly; + let text = "가나다 라마바"; + let greedy = + |g: &[SGlyph], width: f64| break_line(text, g, 0, width, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, PushOutOnly, false, false, &[]).0; + let ends = |g: &[SGlyph]| candidates(text, g, None, Kinsoku::Hard).iter().map(|b| b.end).collect::>(); + // 50 pt holds "가나다 라" and not "가나다 라마". + assert_eq!(greedy(&korean(text, false), 50.0), 4, "off: the line ends at the space"); + assert_eq!(greedy(&korean(text, true), 50.0), 5, "on: the line ends after 라"); + assert_eq!(ends(&korean(text, false)), [4]); + assert_eq!(ends(&korean(text, true)), [1, 2, 4, 5, 6]); + let params = Params { justify_last: false, ragged: true, tolerance: f64::INFINITY, uniform_from: 0, hyphen_limit: 0 }; + let off = korean(text, false); + let cands = candidates(text, &off, None, Kinsoku::Hard); + assert_eq!(compose(&off, &|_| 50.0, &|_| 0.0, &|_| 0.0, &cands, &[], ¶ms), Some(vec![(4, false), (7, false)])); + // The first word is measured whole, as the line loop measures it. + assert_eq!(breaking::word_width(&off, 0, &[], Kinsoku::Hard), 30.0); + assert_eq!(breaking::word_width(&korean(text, true), 0, &[], Kinsoku::Hard), 10.0); + // A word wider than the line still breaks, at the syllable that overflows. + assert_eq!(greedy(&korean("가나다라마바", false), 25.0), 2); + // A soft return still ends the line. + let soft = "가나\u{2028}다라"; + assert_eq!( + break_line(soft, &korean(soft, false), 0, 100.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, PushOutOnly, false, false, &[]).0, + 3 + ); +} + +/// Korean Line Break Anywhere off keeps the Han, kana and full-width letters of a Korean word with +/// it, lets the line break after East Asian punctuation in it (as kinsoku allows), and leaves +/// text without Hangul its own breaks. On, No Break still keeps its characters together. +#[test] +fn korean_words_keep_their_letters_and_break_after_punctuation() { + let ends = |text: &str, anywhere: bool| candidates(text, &korean(text, anywhere), None, Kinsoku::Hard).iter().map(|b| b.end).collect::>(); + assert_eq!(ends("漢字한국 ab", false), [5], "Han in a Korean word stays with it"); + assert_eq!(ends("日本語 한국어", false), [1, 2, 4], "Japanese keeps its breaks"); + assert_eq!(ends("가나。다라", false), [3], "after the full stop"); + assert_eq!(ends("「가나」다", false), [4], "after the closing bracket, not the opening one"); + assert_eq!(ends("가나AB다", false), Vec::::new(), "full-width letters stay with it"); + let text = "가나다라"; + let mut glyphs = korean(text, true); + for g in &mut glyphs[1..3] { + g.no_break = true; + } + let end = break_line(text, &glyphs, 0, 25.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false, &[]).0; + assert_eq!(end, 1, "No Break keeps 나다 together"); +} + +/// Hyphenation adds no hyphen to a Korean word, even where a hyphenation exception marks one: the +/// word that overflows breaks without one, and the every-line composer is offered no hyphenated +/// break in it. +#[test] +fn korean_words_take_no_hyphen() { + let opts = HyphenationOptions { words_longer_than: 0, after_first: 1, before_last: 1, ..Default::default() }; + let text = "가나다라"; + crate::hyphen::with_hyphenation_exceptions_for_test("가나-다라", || { + let glyphs = korean(text, false); + let line = break_line( + text, + &glyphs, + 0, + 25.0, + &[], + 0.0, + Some((&opts, 0.0)), + Burasagari::None, + Kinsoku::Hard, + KinsokuShoriType::PushOutOnly, + false, + false, + &[], + ); + assert_eq!(line, (2, false)); + assert!(candidates(text, &glyphs, Some(&opts), Kinsoku::Hard).iter().all(|b| b.hyphen == 0.0)); + }); + // Nor between the Latin letters of a word that also holds Hangul. + let text = "가나foobar"; + crate::hyphen::with_hyphenation_exceptions_for_test("가나foo-bar", || { + let hyphens = + |anywhere: bool| candidates(text, &korean(text, anywhere), Some(&opts), Kinsoku::Hard).iter().filter(|b| b.hyphen > 0.0).count(); + assert_eq!(hyphens(false), 0); + assert_eq!(hyphens(true), 1, "on, the word's Latin letters hyphenate as before"); + }); +} diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index bb258ff225..ba7339bc6b 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -76,6 +76,9 @@ pub(crate) struct SGlyph { /// A word of a script written without spaces (Thai, Lao, Khmer, Myanmar) ends after the /// glyph's cluster, so a line may break there ([`mark_word_ends`]). pub word_end: bool, + /// A letter of a Korean word kept whole (Korean Line Break Anywhere off): a CJK character + /// here doesn't let the line break after it ([`keep_korean_words`]). + pub keeps_word: bool, /// No Break (Character panel menu): keep this glyph on the same line as the neighbouring /// glyphs that have it. pub no_break: bool, @@ -158,7 +161,7 @@ impl SGlyph { self.is_space() || self.word_end || matches!(self.ch, '-' | '\u{2010}' | '\u{2013}' | '\u{2014}' | '/' | SOFT_HYPHEN | '\u{2028}') - || is_cjk(self.ch) + || (is_cjk(self.ch) && !self.keeps_word) } /// A soft (discretionary) hyphen: invisible unless a line breaks after it. pub fn is_soft_hyphen(&self) -> bool { @@ -327,6 +330,30 @@ pub(crate) fn mark_word_ends(text: &str, start: usize, sg: &mut [SGlyph]) { } } +/// Korean Line Break Anywhere off: mark the letters of each Korean word of `sg` (a paragraph's +/// glyphs) as [`SGlyph::keeps_word`], so the word breaks only at its spaces and punctuation. A +/// Korean word is the run of glyphs between spaces and forced line breaks when any of them is +/// Hangul; its Han, kana and full-width letters are kept with it. Punctuation in it still lets +/// the line break after it, and runs without Hangul keep their own breaks. +pub(crate) fn keep_korean_words(sg: &mut [SGlyph]) { + let mut start = 0; + while start < sg.len() { + let len = sg[start..].iter().position(|g| g.is_space() || g.ch == '\u{2028}').unwrap_or(sg.len() - start); + let word = &mut sg[start..start + len]; + if word.iter().any(|g| g.inline.is_none() && is_hangul(g.ch)) { + for g in word.iter_mut().filter(|g| g.inline.is_none()) { + g.keeps_word = g.ch.is_alphanumeric(); + } + } + start += len + 1; + } +} + +/// Is `c` Hangul: a syllable, a jamo or a compatibility jamo (full or half width)? +pub(crate) fn is_hangul(c: char) -> bool { + c.script() == Script::Hangul +} + pub(crate) fn is_cjk(c: char) -> bool { matches!(c as u32, 0x2E80..=0x9FFF | 0xAC00..=0xD7AF | 0xF900..=0xFAFF | 0xFF00..=0xFFEF | 0x20000..=0x2FFFF) } @@ -1057,6 +1084,7 @@ fn inline_glyph( level, inline: Some(InlineBox { art: drawn, xf }), word_end: false, + keeps_word: false, no_break: st.no_break, vertical_roman: false, cursive: false, @@ -1489,6 +1517,7 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe level: *level, inline: None, word_end: false, + keeps_word: false, no_break: gst.no_break, vertical_roman: gst.standard_vertical_roman, cursive, diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index cdd4248220..bcb233b8c3 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -2516,6 +2516,41 @@ fn thai_loanwords_stay_whole() { assert!(l.lines.iter().all(|ln| ln.end <= word || ln.end >= word + "โปรแกรม".len()), "{lines:?}"); } +/// Korean Line Break Anywhere (a paragraph attribute): off, a Korean word that does not fit moves +/// to the next line whole, with either composer and in vertical type; on, the line breaks between +/// its syllables. Each paragraph follows its own setting. +#[test] +fn korean_words_stay_whole_unless_line_break_anywhere_is_on() { + let text = "가나다 라마바"; + let first = "가나다 ".len(); + let measure = |vertical: bool| { + let mut t = point("가나다 라", style(20.0)); + t.vertical = vertical; + layout(db(), &t).glyphs.iter().map(|g| g.advance).sum::() + }; + let ends = |t: &TextObject| layout(db(), t).lines.iter().map(|l| l.end).collect::>(); + for vertical in [false, true] { + let fits = measure(vertical); + let frame = if vertical { Rect::new(0.0, 0.0, 400.0, fits + 1.0) } else { Rect::new(0.0, 0.0, fits + 1.0, 400.0) }; + for composer in [Composer::SingleLine, Composer::EveryLine] { + let mut t = area(text, style(20.0), frame, Justify::Left); + (t.vertical, t.para.composer) = (vertical, composer); + assert_eq!(ends(&t), [first, text.len()], "{composer:?} vertical {vertical}: off"); + t.para.korean_line_break_anywhere = true; + let on = ends(&t); + assert!(on.iter().any(|&e| e != first && e != text.len()), "{composer:?} vertical {vertical}: on {on:?}"); + } + } + // Per paragraph: the first breaks between syllables, the second between words. + let fits = measure(false); + let mut t = area(&format!("{text}\n{text}"), style(20.0), Rect::new(0.0, 0.0, fits + 1.0, 400.0), Justify::Left); + t.para.composer = Composer::SingleLine; + let anywhere = vectorcraft_doc::ParaStyle { korean_line_break_anywhere: true, ..t.para.clone() }; + t.set_paragraph_styles(vec![anywhere, t.para.clone()]); + let second = text.len() + 1; + assert_eq!(ends(&t), ["가나다 라".len(), text.len(), second + first, second + text.len()]); +} + /// No Break (Character panel menu) keeps the marked words on one line, where the same text wraps /// at the space without the mark. #[test] diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 85ddda3332..c25fa2f7ed 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -326,6 +326,7 @@ registry! { BrushReplaceArt: [brush_replace_art::KIND] => brush_replace_art::SPEC, Message: [confirm::MESSAGE] => confirm::MESSAGE_SPEC, Replace: [confirm::REPLACE] => confirm::REPLACE_SPEC, + ReplaceFiles: [confirm::REPLACE_FILES] => confirm::REPLACE_FILES_SPEC, ResetPreferences: [confirm::RESET_PREFS] => confirm::RESET_PREFS_SPEC, NewSwatch: [new_swatch::KIND] => new_swatch::SPEC, NewColorGroup: [new_color_group::KIND] => new_color_group::SPEC, diff --git a/crates/ui-egui/src/dialogs/confirm.rs b/crates/ui-egui/src/dialogs/confirm.rs index 06e96e45fe..6f51be44d9 100644 --- a/crates/ui-egui/src/dialogs/confirm.rs +++ b/crates/ui-egui/src/dialogs/confirm.rs @@ -63,6 +63,52 @@ pub(crate) fn ask_replace(app: &mut VectorcraftApp, path: &str, typed: &str) { app.ui.dialog = Some(Dialog::new(REPLACE, fields)); } +/// The dialog kind of the question whether a save or export may replace the files it writes beside +/// the one picked in the save panel ([`ask_replace_files`]). Fields: `message`, `detail`, `files` +/// (their paths), `allowed` (files an earlier OK allowed), `doc` (the document's uid), `__command` +/// and `__params`. +pub const REPLACE_FILES: &str = "replaceFiles"; + +pub(super) const REPLACE_FILES_SPEC: DialogSpec = DialogSpec { confirm: replace_files, ..SPEC }; + +/// Ask whether to replace `files`, files that are there beside the one a save or export of the +/// document `doc` (its uid) writes, which the save panel did not ask about (a file per artboard, a +/// linked image). OK runs `command` with `params` again on that document, now replacing those +/// files and `allowed` (those an earlier OK allowed); Cancel writes nothing. +pub(crate) fn ask_replace_files(app: &mut VectorcraftApp, doc: u64, files: &[String], allowed: &[String], command: &str, params: Value) { + let name = files.first().map(|f| vectorcraft_engine::cmd::fileio::file_name(f)).unwrap_or_default(); + let message = crate::i18n::fmt(tl!("“{name}” already exists. Replace it?"), &[("name", &name)]); + // Several: every one of them, by name. + let detail = match files { + [_, _, ..] => files.iter().map(|f| vectorcraft_engine::cmd::fileio::file_name(f)).collect::>().join("\n"), + _ => String::new(), + }; + let fields = + json!({"message": message, "detail": detail, "files": files, "allowed": allowed, "doc": doc, "__command": command, "__params": params}); + let d = Dialog::new(REPLACE_FILES, fields); + // Asked when a Background Save or Export ends: a dialog open then stays, and this waits its turn + // (however many messages wait: the save or export needs the answer). + match app.ui.dialog { + None => app.ui.dialog = Some(d), + Some(_) => app.ui.told.push(d), + } +} + +/// OK on [`ask_replace_files`]: what asked runs again on its document, and the files the question +/// named are replaced; one it writes now that was not named is asked about again. The save or export +/// reads the allowance when it starts, before a background one hands its work to the worker. +fn replace_files(app: &mut VectorcraftApp, d: &Dialog) -> Result { + app.ui.dialog = None; + if !crate::picks::on_document(app, d.fields.get("doc").and_then(Value::as_u64)) { + return Ok(Value::Null); + } + let paths = |k: &str| d.fields.get(k).and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(str::to_string); + app.picks.replace_files_ok = paths("allowed").chain(paths("files")).collect(); + let r = confirm(app, d); + app.picks.replace_files_ok.clear(); + r +} + /// The dialog kind of the question Reset Preferences asks over the Preferences dialog /// ([`ask_reset_prefs`]). Fields: `message`, `detail`. pub const RESET_PREFS: &str = "resetPreferences"; diff --git a/crates/ui-egui/src/dialogs/para_style_options.rs b/crates/ui-egui/src/dialogs/para_style_options.rs index 34a623cc5c..1d59bdd679 100644 --- a/crates/ui-egui/src/dialogs/para_style_options.rs +++ b/crates/ui-egui/src/dialogs/para_style_options.rs @@ -1,6 +1,7 @@ //! Paragraph Style Options fields shared by editing and creating a paragraph style: General, Basic //! Paragraph Formats (alignment, indents and spacing), Tabs (the style's tab stops), Composition -//! (composer and Roman Hanging Punctuation), Hyphenation and Justification. +//! (composer and Roman Hanging Punctuation), Hyphenation, Justification and East Asian Composition +//! Settings (Korean Line Break Anywhere). //! //! Each field is unset (empty, or a checkbox in its unset state) or set, and an unset field leaves //! its attribute out of the style. Distances are shown and typed in the General unit and kept in @@ -23,7 +24,7 @@ use crate::state::Dialog; pub const KIND: &str = "paraStyleOptions"; /// The section ids, in the order the section list shows them. -pub(crate) const SECTIONS: [&str; 6] = ["general", "basic", "tabs", "composition", "hyphenation", "justification"]; +pub(crate) const SECTIONS: [&str; 7] = ["general", "basic", "tabs", "composition", "hyphenation", "justification", "eastAsian"]; /// The alignments of a tab stop with the names the `tabs` field gives them, in the Tabs panel's /// order. const TAB_ALIGNS: [(TabAlign, &str); 4] = @@ -104,6 +105,7 @@ pub(super) const SPEC: DialogSpec = DialogSpec { "glyph_max", "auto_leading", "single_word", + "korean_line_break_anywhere", "tabs", "section", ] @@ -232,7 +234,7 @@ pub(crate) fn fields_from_attrs(attrs: &Map, unit: Unit) -> Map Result, String> { let same = unchanged("tabs", "__resetTabs"); let value = if same { None } else { tab_stops(d, &original, unit)? }; keep_or(&mut attrs, "tabs", same, value); + + let same = unchanged("korean_line_break_anywhere", "__resetEastAsian"); + keep_or(&mut attrs, "korean_line_break_anywhere", same, switch("korean_line_break_anywhere")); Ok(attrs) } @@ -554,9 +559,15 @@ fn reset_tabs(d: &mut Dialog) { d.fields.insert("__tabSel".into(), json!(0)); } +/// Reset Panel in East Asian Composition Settings: Korean Line Break Anywhere becomes unset. +fn reset_east_asian(d: &mut Dialog) { + d.fields.insert("__resetEastAsian".into(), json!(true)); + d.fields.insert("korean_line_break_anywhere".into(), Value::Null); +} + /// Whether the section `d` shows has a Reset Panel: every section but General. pub(crate) fn can_reset(d: &Dialog) -> bool { - ["basic", "tabs", "composition", "hyphenation", "justification"].contains(&d.str("section").as_str()) + ["basic", "tabs", "composition", "hyphenation", "justification", "eastAsian"].contains(&d.str("section").as_str()) } /// Reset Panel: clear the settings of the section `d` shows. @@ -567,6 +578,7 @@ pub(crate) fn reset_section(d: &mut Dialog) { "composition" => reset_composition(d), "hyphenation" => reset_hyphenation(d), "justification" => reset_justification(d), + "eastAsian" => reset_east_asian(d), _ => {} } } @@ -588,7 +600,15 @@ pub fn reset_panel(app: &mut VectorcraftApp) -> Result { /// The section list of Paragraph Style Options and New Paragraph Style. pub(crate) fn section_list(ui: &mut egui::Ui, d: &mut Dialog) { ui.vertical(|ui| { - let labels = [tl!("General"), tl!("Basic Paragraph Formats"), tl!("Tabs"), tl!("Composition"), tl!("Hyphenation"), tl!("Justification")]; + let labels = [ + tl!("General"), + tl!("Basic Paragraph Formats"), + tl!("Tabs"), + tl!("Composition"), + tl!("Hyphenation"), + tl!("Justification"), + tl!("East Asian Composition Settings"), + ]; for (id, label) in SECTIONS.into_iter().zip(labels) { if ui.selectable_label(d.str("section") == id, label).clicked() { d.fields.insert("section".into(), json!(id)); @@ -885,6 +905,14 @@ pub(crate) fn justification_body(ui: &mut egui::Ui, d: &mut Dialog) { }); } +/// East Asian Composition Settings: Korean Composition Settings › Korean Line Break Anywhere. +pub(crate) fn east_asian_body(ui: &mut egui::Ui, d: &mut Dialog) { + ui.strong(tl!("Korean Composition Settings")); + egui::Grid::new("paragraph-style-east-asian").num_columns(2).spacing([18.0, 8.0]).show(ui, |ui| { + switch(ui, d, "korean_line_break_anywhere", tl!("Korean Line Break Anywhere"), true); + }); +} + /// The heading of a spacing column, for the accessible name of its field. fn column_name(column: &str) -> &'static str { match column { @@ -916,6 +944,7 @@ pub(crate) fn section_body(ui: &mut egui::Ui, d: &mut Dialog) { "composition" => composition_body(ui, d), "hyphenation" => hyphenation_body(ui, d), "justification" => justification_body(ui, d), + "eastAsian" => east_asian_body(ui, d), _ => general_body(ui, d), } } diff --git a/crates/ui-egui/src/dialogs/para_style_options/tests.rs b/crates/ui-egui/src/dialogs/para_style_options/tests.rs index 38d158b102..467b0dc1ae 100644 --- a/crates/ui-egui/src/dialogs/para_style_options/tests.rs +++ b/crates/ui-egui/src/dialogs/para_style_options/tests.rs @@ -157,7 +157,8 @@ fn invalid_values_keep_the_dialog_open() { fn reset_panel_clears_only_its_section() { let mut app = app(); let all = json!({"justify": "Center", "space_after": 6.0, "composer": "singleLine", "roman_hanging": true, "hyphenate": true, - "hyphenation": {"words_longer_than": 8}, "justification": {"auto_leading": 150.0}, "tabs": [{"position": 20.0}]}); + "hyphenation": {"words_longer_than": 8}, "justification": {"auto_leading": 150.0}, "tabs": [{"position": 20.0}], + "korean_line_break_anywhere": true}); app.run("paraStyle.new", json!({"name": "Body", "attrs": all})).unwrap(); let stored = def(&app, "Body"); for (section, cleared) in [ @@ -166,6 +167,7 @@ fn reset_panel_clears_only_its_section() { ("composition", &["composer", "roman_hanging"]), ("hyphenation", &["hyphenate", "hyphenation"]), ("justification", &["justification"]), + ("eastAsian", &["korean_line_break_anywhere"]), ] { app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); assert!(app.run("ui.paraStyleOptions.resetPanel", json!({})).is_err(), "General has no Reset Panel"); @@ -266,6 +268,24 @@ fn new_paragraph_style_offers_the_sections() { assert_eq!(paras(&app, id)[0].justify, Justify::Center); } +/// East Asian Composition Settings › Korean Line Break Anywhere sets the style's attribute, which +/// its paragraphs take; unset, the style leaves it out and the paragraphs keep their own. +#[test] +fn korean_line_break_anywhere_is_set_by_the_east_asian_section() { + let mut app = app(); + let id = app.run("text.create", json!({"x": 10, "y": 40, "text": "가나 다라"})).unwrap()["id"].as_u64().unwrap(); + app.run("paraStyle.new", json!({"name": "Body", "attrs": {}})).unwrap(); + app.run("paraStyle.apply", json!({"name": "Body", "id": id})).unwrap(); + assert!(!paras(&app, id)[0].korean_line_break_anywhere); + app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); + assert!(app.ui.dialog.as_ref().unwrap().fields["korean_line_break_anywhere"].is_null()); + set(&mut app, "section", json!("eastAsian")); + set(&mut app, "korean_line_break_anywhere", json!(true)); + super::super::confirm(&mut app).unwrap(); + assert_eq!(def(&app, "Body").get("korean_line_break_anywhere"), Some(&json!(true))); + assert!(paras(&app, id)[0].korean_line_break_anywhere, "the style's paragraphs take it"); +} + /// The rows of the `tabs` field of the open dialog. fn tab_rows(app: &VectorcraftApp) -> Vec { app.ui.dialog.as_ref().unwrap().fields["tabs"].as_array().cloned().unwrap_or_default() @@ -513,6 +533,7 @@ fn every_section_draws_its_fields() { ("composition", "Roman Hanging Punctuation"), ("hyphenation", "Hyphenation Zone:"), ("justification", "Glyph Scaling:"), + ("eastAsian", "Korean Line Break Anywhere"), ] { set(&mut app, "section", json!(section)); let mut texts = String::new(); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 33195b635c..e666fcc8c6 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2842,6 +2842,9 @@ digits Malayalam Малаялам Rise… Издигане… Roman Baseline Латинска основна линия Roman Hanging Punctuation Латинска висяща пунктуация + Korean Line Break Anywhere Пренасяне на корейски текст навсякъде + East Asian Composition Settings Настройки за източноазиатска композиция + Korean Composition Settings Настройки за корейска композиция Rotate Завъртане Rotate 90° CW Завъртане на 90° по часовниковата стрелка Rotate Tool Инструмент „Завъртане“ diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index fbd31abe10..2381deb135 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -1990,6 +1990,9 @@ digits Malayalam Malajalam Right Indent Desna uvlaka Right-to-Left Paragraph Direction Smjer pasusa s desna nalijevo Roman Hanging Punctuation Latinična viseća interpunkcija + Korean Line Break Anywhere Prelom korejskog reda bilo gdje + East Asian Composition Settings Postavke istočnoazijske kompozicije + Korean Composition Settings Postavke korejske kompozicije Select a text object to edit its paragraph attributes. Odaberite tekstualni objekat da uredite njegove atribute pasusa. Single-line Composer Slagač jednog reda Toggle Line Composer Uključi/isključi Line Composer diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index ecd4cea739..cd6dfad587 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -3644,6 +3644,9 @@ digits Malayalam Malaiàlam Roman Romà Roman Baseline Línia de base romana Roman Hanging Punctuation Puntuació penjant romana + Korean Line Break Anywhere Salt de línia coreà a qualsevol lloc + East Asian Composition Settings Configuració de composició de l’Àsia oriental + Korean Composition Settings Configuració de composició coreana Rotate Gira Rotate 90° CW Gira 90° en sentit horari Rotate Tool Eina de gir diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 54f090e929..00de3e7057 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2017,6 +2017,9 @@ digits Malayalam Malajálamské Right Indent Odsazení zprava Right-to-Left Paragraph Direction Směr odstavce zprava doleva Roman Hanging Punctuation Visící latinková interpunkce + Korean Line Break Anywhere Zalomení korejského řádku kdekoli + East Asian Composition Settings Nastavení východoasijské sazby + Korean Composition Settings Nastavení korejské sazby Select a text object to edit its paragraph attributes. Vyberte textový objekt, abyste mohli upravit atributy jeho odstavce. Single-line Composer Sazba jednoho řádku Toggle Line Composer Přepnout sazbu řádků diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index b093a60dfc..f48e9d6ea3 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -1823,6 +1823,9 @@ digits Malayalam Malayalam Right Indent Højre indrykning Right-to-Left Paragraph Direction Afsnitsretning fra højre mod venstre Roman Hanging Punctuation Romersk hængende tegnsætning + Korean Line Break Anywhere Koreansk linjeskift hvor som helst + East Asian Composition Settings Indstillinger for østasiatisk komposition + Korean Composition Settings Indstillinger for koreansk komposition Select a text object to edit its paragraph attributes. Markér et tekstobjekt for at redigere dets afsnitsattributter. Single-line Composer Enlinjesætter Toggle Line Composer Skift linjesætter diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 71687e2275..0de0dce0b9 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2884,6 +2884,9 @@ digits Malayalam Malayalam Rise… Ansteigend… Roman Baseline Lateinische Grundlinie Roman Hanging Punctuation Hängende Interpunktion (lateinisch) + Korean Line Break Anywhere Koreanischer Zeilenumbruch überall + East Asian Composition Settings Einstellungen für ostasiatischen Satz + Korean Composition Settings Einstellungen für koreanischen Satz Rotate Drehen Rotate 90° CW Um 90° im Uhrzeigersinn drehen Rotate Tool Drehen-Werkzeug diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 3ae15d9451..a75edff7a8 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1964,6 +1964,9 @@ font-technology Variable Μεταβλητές Right: Δεξιά: Rise… Ανύψωση… Roman Hanging Punctuation Κρεμαστή στίξη όρθιας γραφής + Korean Line Break Anywhere Αλλαγή γραμμής κορεατικών οπουδήποτε + East Asian Composition Settings Ρυθμίσεις σύνθεσης ανατολικής Ασίας + Korean Composition Settings Ρυθμίσεις κορεατικής σύνθεσης Rotate Περιστροφή Rotate View Tool Εργαλείο περιστροφής προβολής (Rotate View Tool) Trackpad Gesture to Rotate View Περιστροφή προβολής με χειρονομία στην επιφάνεια αφής diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 139d94c386..6d548f6743 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2917,6 +2917,9 @@ digits Malayalam Malayálam Rise… Ascenso… Roman Baseline Línea de base latina Roman Hanging Punctuation Puntuación colgante romana + Korean Line Break Anywhere Salto de línea coreano en cualquier lugar + East Asian Composition Settings Ajustes de composición de Asia oriental + Korean Composition Settings Ajustes de composición coreana Rotate Rotar Rotate 90° CW Rotar 90° en sentido horario Rotate Tool Herramienta Rotar diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 5b0c4826fe..7ae54d2bef 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -1978,6 +1978,9 @@ digits Malayalam Malajalam Right Indent Oikea sisennys Right-to-Left Paragraph Direction Kappaleen suunta oikealta vasemmalle Roman Hanging Punctuation Latinalaiset riippuvat välimerkit + Korean Line Break Anywhere Korean rivinvaihto missä tahansa + East Asian Composition Settings Itäaasialaisen ladonnan asetukset + Korean Composition Settings Korean ladonnan asetukset Select a text object to edit its paragraph attributes. Valitse tekstiobjekti, jonka kappalemääritteitä haluat muokata. Single-line Composer Yhden rivin asettelu Toggle Line Composer Vaihda rivien asettelu diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index a8ca4d3db9..35c649f895 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2909,6 +2909,9 @@ digits Malayalam Malayalam Rise… Élévation… Roman Baseline Ligne de base romaine Roman Hanging Punctuation Ponctuation romaine hors justification + Korean Line Break Anywhere Saut de ligne coréen n’importe où + East Asian Composition Settings Paramètres de composition d’Asie orientale + Korean Composition Settings Paramètres de composition coréenne Rotate Rotation Rotate 90° CW Rotation de 90° horaire Rotate Tool Outil Rotation diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 38a2b995e2..335b0c99f0 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -3976,6 +3976,9 @@ antiAlias Sharp शार्प Roman रोमन Roman Baseline रोमन बेसलाइन Roman Hanging Punctuation रोमन हैंगिंग विराम चिह्न + Korean Line Break Anywhere कोरियाई लाइन ब्रेक कहीं भी + East Asian Composition Settings पूर्वी एशियाई संरचना सेटिंग्स + Korean Composition Settings कोरियाई संरचना सेटिंग्स Rotate घुमाएँ Rotate 90° CW 90° सीडब्ल्यू Rotate Tool रोटेट टूल diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 35935064e8..c3ed22bce8 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3492,6 +3492,9 @@ mark-style Japanese Japán Roman Latin Roman Baseline Latin alapvonal Roman Hanging Punctuation Latin függő írásjelek + Korean Line Break Anywhere Koreai sortörés bárhol + East Asian Composition Settings Kelet-ázsiai szedési beállítások + Korean Composition Settings Koreai szedési beállítások Rotate Elforgatás Rotate 90° CW Forgatás 90°-kal jobbra Rotate Tool Elforgatás diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 52584e388c..87c3f55cd5 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1823,6 +1823,9 @@ digits Malayalam Malayalam Right Indent Indentasi kanan Right-to-Left Paragraph Direction Arah paragraf kanan ke kiri Roman Hanging Punctuation Tanda baca gantung Romawi + Korean Line Break Anywhere Putus baris Korea di mana saja + East Asian Composition Settings Pengaturan komposisi Asia Timur + Korean Composition Settings Pengaturan komposisi Korea Select a text object to edit its paragraph attributes. Pilih objek teks untuk mengedit atribut paragrafnya. Single-line Composer Komposer satu baris Toggle Line Composer Alihkan komposer baris diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 5b24ab089e..a6edc31792 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2960,6 +2960,9 @@ digits Malayalam Malayalam Rise… Ascesa… Roman Baseline Linea di base romana Roman Hanging Punctuation Punteggiatura sporgente romana + Korean Line Break Anywhere Interruzione di riga coreana ovunque + East Asian Composition Settings Impostazioni di composizione dell’Asia orientale + Korean Composition Settings Impostazioni di composizione coreana Rotate Ruota Rotate 90° CW Ruota di 90° in senso orario Rotate Tool Strumento Ruota diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 6d7199333d..65d7721aa1 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2946,6 +2946,9 @@ mark-style Japanese 日本式 Right Plane 右面 Right: 右: Roman Hanging Punctuation 欧文ぶら下がり + Korean Line Break Anywhere 韓国語の任意位置での改行 + East Asian Composition Settings 東アジア言語の組版設定 + Korean Composition Settings 韓国語の組版設定 Rotate 回転 Rotate Tool 回転ツール Rotate View Tool 回転ビューツール diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index cd85de4b04..eb686a14a0 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -1749,6 +1749,9 @@ digits Malayalam მალაიალამური Right Indent მარჯვენა შეწევა Right-to-Left Paragraph Direction აბზაცის მიმართულება მარჯვნიდან მარცხნივ Roman Hanging Punctuation ლათინური გამოწეული პუნქტუაცია + Korean Line Break Anywhere კორეული სტრიქონის გადატანა ნებისმიერ ადგილას + East Asian Composition Settings აღმოსავლეთ აზიური აწყობის პარამეტრები + Korean Composition Settings კორეული აწყობის პარამეტრები Select a text object to edit its paragraph attributes. აბზაცის ატრიბუტების რედაქტირებისთვის მონიშნეთ ტექსტური ობიექტი. Single-line Composer სტრიქონის მიხედვით აწყობა Toggle Line Composer აწყობის რეჟიმის გადართვა diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index cfd930126d..52a10ab8e8 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2935,6 +2935,9 @@ mark-style Japanese 일본식 Right Plane 오른쪽 평면 Right: 오른쪽: Roman Hanging Punctuation 로마자 걸림 문장 부호 + Korean Line Break Anywhere 한글 어디서나 줄 바꿈 + East Asian Composition Settings 동아시아 조판 설정 + Korean Composition Settings 한글 조판 설정 Rotate 회전 Rotate Tool 회전 도구 Rotate View Tool 보기 회전 도구 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index dc7a63802d..4f9d484155 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -1837,6 +1837,9 @@ digits Malayalam Malayalam Right Indent Rechts inspringen Right-to-Left Paragraph Direction Alinearichting van rechts naar links Roman Hanging Punctuation Romeinse hangende leestekens + Korean Line Break Anywhere Koreaans regeleinde overal + East Asian Composition Settings Instellingen voor Oost-Aziatische zetting + Korean Composition Settings Instellingen voor Koreaanse zetting Select a text object to edit its paragraph attributes. Selecteer een tekstobject om de alineakenmerken ervan te bewerken. Single-line Composer Compositie per regel Toggle Line Composer Compositiemethode wisselen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index e296e22e01..e44328480f 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1935,6 +1935,9 @@ digits Malayalam Malajalam Right Indent Wcięcie z prawej Right-to-Left Paragraph Direction Kierunek akapitu od prawej do lewej Roman Hanging Punctuation Wysunięta interpunkcja łacińska + Korean Line Break Anywhere Koreański podział wiersza w dowolnym miejscu + East Asian Composition Settings Ustawienia składu wschodnioazjatyckiego + Korean Composition Settings Ustawienia składu koreańskiego Select a text object to edit its paragraph attributes. Zaznacz obiekt tekstowy, aby edytować atrybuty akapitu. Single-line Composer Kompozytor jednowierszowy Toggle Line Composer Przełącz kompozytor wierszy diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index e9cb68d011..3ce111f7a4 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2447,6 +2447,9 @@ digits Malayalam Malaiala Rise… Rise… Roman Baseline Linha de Base Romana Roman Hanging Punctuation Pontuação Pendurada Romana + Korean Line Break Anywhere Quebra de Linha Coreana em Qualquer Lugar + East Asian Composition Settings Configurações de Composição do Leste Asiático + Korean Composition Settings Configurações de Composição Coreana Rotate Girar Rotate Tool Ferramenta Girar Rotate View Tool Ferramenta Girar Visualização diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 4411d9ba83..c93807c08a 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2947,6 +2947,9 @@ digits Malayalam Малаялам Rise… Подъём… Roman Baseline Римская базовая линия Roman Hanging Punctuation Римская висячая пунктуация + Korean Line Break Anywhere Перенос корейского текста в любом месте + East Asian Composition Settings Параметры восточноазиатского набора + Korean Composition Settings Параметры корейского набора Rotate Повернуть Rotate 90° CW Повернуть на 90° по часовой стрелке Rotate Tool Инструмент «Поворот» diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 81d8ad393a..ce20b6c72d 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2853,6 +2853,9 @@ digits Malayalam Malayalam Rise… Stigning… Roman Baseline Baslinje (roman) Roman Hanging Punctuation Hängande interpunktion (roman) + Korean Line Break Anywhere Koreansk radbrytning var som helst + East Asian Composition Settings Inställningar för östasiatisk sättning + Korean Composition Settings Inställningar för koreansk sättning Rotate Rotera Rotate 90° CW Rotera 90° medurs Rotate Tool Rotationsverktyg diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 20a58d7bfd..db3c4501b7 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2720,6 +2720,9 @@ digits Malayalam Malayalam Rise… Yüksel… Roman Baseline Roma Taban Çizgisi Roman Hanging Punctuation Roma Asılı Noktalama İşaretleri + Korean Line Break Anywhere Korece Satırı Her Yerde Kes + East Asian Composition Settings Doğu Asya Dizgi Ayarları + Korean Composition Settings Korece Dizgi Ayarları Rotate Döndür Rotate 90° CW 90° CW döndür Rotate Tool Döndürme Aracı diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 3b19cfcb4e..151a886cf9 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2952,6 +2952,9 @@ digits Malayalam Малаялам Rise… Підйом… Roman Baseline Римська базова лінія Roman Hanging Punctuation Римська висяча пунктуація + Korean Line Break Anywhere Перенесення корейського тексту будь-де + East Asian Composition Settings Параметри східноазійського набору + Korean Composition Settings Параметри корейського набору Rotate Повернути Rotate 90° CW Повернути на 90° за годинниковою стрілкою Rotate Tool Інструмент «Поворот» diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 449dd0f2b0..f3b505c648 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3256,6 +3256,9 @@ mark-style Japanese Kiểu Nhật Rise… Nâng lên… Roman Baseline Đường cơ sở Roman Roman Hanging Punctuation Dấu câu treo Roman + Korean Line Break Anywhere Ngắt dòng tiếng Hàn ở bất kỳ đâu + East Asian Composition Settings Cài đặt dàn trang Đông Á + Korean Composition Settings Cài đặt dàn trang tiếng Hàn Rotate Xoay Rotate 90° CW Xoay 90° theo chiều kim đồng hồ Rotate Tool Công cụ Xoay diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 2f7e38b6e4..c7219b9448 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2816,6 +2816,9 @@ digits Malayalam 马拉雅拉姆数字 Rise… 上升… Roman Baseline 罗马基线 Roman Hanging Punctuation 西文悬挂标点 + Korean Line Break Anywhere 韩文任意位置换行 + East Asian Composition Settings 东亚排版设置 + Korean Composition Settings 韩文排版设置 Rotate 旋转 Rotate 90° CW 顺时针旋转 90° Rotate Tool 旋转工具 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index edb12b6106..1c753576a5 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3925,6 +3925,9 @@ digits Malayalam 馬來亞拉姆數字 Rise… 上升… Roman Baseline 羅馬基線 Roman Hanging Punctuation 羅馬懸掛標點 + Korean Line Break Anywhere 韓文任意位置換行 + East Asian Composition Settings 東亞排版設定 + Korean Composition Settings 韓文排版設定 Rotate 旋轉 Rotate 90° CW 順時針旋轉 90° Rotate Tool 旋轉工具 diff --git a/crates/ui-egui/src/io/export.rs b/crates/ui-egui/src/io/export.rs index 7e5bc01380..03e0196267 100644 --- a/crates/ui-egui/src/io/export.rs +++ b/crates/ui-egui/src/io/export.rs @@ -1,6 +1,7 @@ use super::save::{ - ask_before_losing, export_path, is_web, notes_of, remember_active_artboard, report_saved, report_write_error, saved_status, show_with_notes, - target_path, target_path_keeping, view_file, write_encoded, write_named, write_reporting, + ask_before_losing, ask_to_replace_files, checks_replaced, export_path, is_web, notes_of, remember_active_artboard, replaced_files, report_saved, + report_write_error, saved_status, show_with_notes, target_path, target_path_keeping, view_file, with_path, write_encoded, write_named, + write_reporting, }; use super::{EngineError, Progress, Value, VectorcraftApp, Writer, background, cancel, fileio, json, progress}; @@ -44,6 +45,7 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option std::sync::Arc::new(d), }; let doc = fileio::titled_for_export(&doc, st.path.as_deref(), f.id).map_or(doc, std::sync::Arc::new); + let uid = st.uid; let path = export_path(app, path, f)?; let mut again = params.clone(); if let Some(o) = again.as_object_mut() { @@ -68,11 +70,18 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option 1 { @@ -90,6 +99,11 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option = files.iter().filter_map(Value::as_str).map(str::to_string).collect(); + return Ok(ask_to_replace_files(app, uid, &files, &allowed_before, "file.exportAs", &rerun)); + } let what = match r["files"].as_array() { Some(files) => format!("{} files", files.len()), None => r["path"].as_str().unwrap_or_default().to_string(), diff --git a/crates/ui-egui/src/io/save.rs b/crates/ui-egui/src/io/save.rs index 42b76ce2d8..d9d475e76f 100644 --- a/crates/ui-egui/src/io/save.rs +++ b/crates/ui-egui/src/io/save.rs @@ -264,7 +264,7 @@ pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bo if ask && matches!(mode, SaveMode::SaveAs | SaveMode::Copy) && matches!(first.format.id, "svg" | "svgz") { return ask_format_options(app, mode, first.format, &path); } - return write_plan(app, first); + return write_plan(app, first, (save_command(mode), with_path(p, &path))); } if is_web(app) && ask_options { return Ok(open_options(app, mode.command(), first.format, &first.name, true)); @@ -291,7 +291,42 @@ pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bo crate::dialogs::opened_by_panel(app); return r; } - write_plan(app, chosen) + write_plan(app, chosen, (save_command(mode), q)) +} + +/// `p` (an object, else none) with `path`. +pub(super) fn with_path(p: &Value, path: &str) -> Value { + let mut p = if p.is_object() { p.clone() } else { json!({}) }; + p["path"] = json!(path); + p +} + +/// The files among `paths` that a write to `path` replaces although the save panel asked only +/// about `path`: every other one that `exists` finds, but those in `known`. None without `exists`. +pub(super) fn replaced_files<'a>( + exists: Option<&crate::ExistsFn>, + path: &str, + known: &[String], + paths: impl IntoIterator, +) -> Vec { + let Some(exists) = exists else { return Vec::new() }; + paths.into_iter().filter(|p| *p != path && !known.iter().any(|k| k == p) && exists(p)).map(str::to_string).collect() +} + +/// The `exists` service with which a save or export checks the files it writes beside the one +/// picked ([`replaced_files`]), none on the web (downloads), and the files it replaces without +/// asking: those the OK of the question about them allowed while it runs that again. +pub(super) fn checks_replaced(app: &VectorcraftApp) -> (Option, Vec) { + (app.services.exists.clone().filter(|_| !is_web(app)), app.picks.replace_files_ok.clone()) +} + +/// Ask before a save or export of the document `doc` (its uid) replaces `files`, files that are +/// there beside the one picked in the save panel (a file per artboard, a linked image), which the +/// panel did not ask about. OK runs `command` with `params` again, replacing them and `allowed` +/// (those an earlier OK allowed); Cancel writes nothing → `{pending}`. +pub(super) fn ask_to_replace_files(app: &mut VectorcraftApp, doc: u64, files: &[String], allowed: &[String], command: &str, params: &Value) -> Value { + dialogs::confirm::ask_replace_files(app, doc, files, allowed, command, params.clone()); + json!({ "pending": dialogs::confirm::REPLACE_FILES }) } /// The UI command that runs a save in `mode`. @@ -433,7 +468,10 @@ pub(crate) fn saved_status(path: &str, r: &Value) -> String { /// ([`background`]): this returns `{path, background: true, status, format, warnings}` at once /// (`warnings`: what the format loses, then the save's notes on its params, such as an option the format /// doesn't read), and the document takes the file (and counts as saved, as of the snapshot) once it is written. -fn write_plan(app: &mut VectorcraftApp, plan: SavePlan) -> Result { +/// A file it writes beside the one at its path that is there already, and that the document's last +/// save did not write, is asked about before anything is written; `again` (a command and its params) +/// runs the save again once the question's OK allows it ([`ask_to_replace_files`]). +fn write_plan(app: &mut VectorcraftApp, plan: SavePlan, again: (&'static str, Value)) -> Result { let uid = app.session.active().ok_or("no document")?.uid; // Saves of one document finish in order. if app.background.busy_with(uid) { @@ -461,10 +499,19 @@ fn write_plan(app: &mut VectorcraftApp, plan: SavePlan) -> Result let done = job.clone(); // A native file streams into the file as it is encoded (desktop; not to a download). let stream = app.services.stream_shared.clone().filter(|_| job.streams() && !is_web(app)); + let (exists, allowed) = checks_replaced(app); + let known: Vec = allowed.iter().chain(app.picks.written_beside.get(&uid).into_iter().flatten()).cloned().collect(); let work = move |write: Writer| { match stream { Some(stream) => job.write_streamed(&mut |p, fill| stream(p, fill).map_err(EngineError::Other)), - None => job.write(|p, b| write(p, b).map_err(EngineError::Other)), + None => job.write_unless( + |paths| { + let path = job.plan().path.as_deref().unwrap_or_default(); + let replaced = replaced_files(exists.as_ref(), path, &known, paths.iter().copied()); + (!replaced.is_empty()).then(|| json!({ "replaces": replaced })) + }, + |p, b| write(p, b).map_err(EngineError::Other), + ), } .map_err(|e| e.to_string()) }; @@ -472,11 +519,20 @@ fn write_plan(app: &mut VectorcraftApp, plan: SavePlan) -> Result let then = move |app: &mut VectorcraftApp, r: Result| { // Not written: the document keeps its file, format and modified state, and the user is told. let r = r.inspect_err(|e| report_write_error(app, false, &failed, e))?; + // Nothing written either: asked first. + if let Some(files) = r.get("replaces").and_then(Value::as_array) { + let files: Vec = files.iter().filter_map(Value::as_str).map(str::to_string).collect(); + return Ok(ask_to_replace_files(app, uid, &files, &allowed, again.0, &again.1)); + } // The document may have closed meanwhile: the file is written all the same. done.complete(&mut app.session, uid); let path = r["path"].as_str().unwrap_or_default().to_string(); if retargets { note_recent(app, &path); + // The files written beside the document's file, which its next save replaces. + let beside = ["files", "linked"].iter().flat_map(|k| r[*k].as_array().into_iter().flatten()).filter_map(Value::as_str); + let beside: Vec = beside.filter(|p| *p != path).map(str::to_string).collect(); + app.picks.written_beside.insert(uid, beside); } report_saved(app, &path, &r); Ok(r) diff --git a/crates/ui-egui/src/panels/paragraph.rs b/crates/ui-egui/src/panels/paragraph.rs index 32d15d715f..cabca387ae 100644 --- a/crates/ui-egui/src/panels/paragraph.rs +++ b/crates/ui-egui/src/panels/paragraph.rs @@ -877,6 +877,11 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { } }); }); + // Korean lines break between any two syllables, not only between words. + let anywhere = shared_para(app, |p| p.korean_line_break_anywhere) == Some(true); + if menu_item(ui, tl!("Korean Line Break Anywhere"), has, anywhere) { + toggle_korean_line_break_anywhere(app); + } } ui.separator(); let composer = shared_composer(app); @@ -899,12 +904,20 @@ fn set_kinsoku_type(app: &mut VectorcraftApp, key: &str) { format(app, json!({"kinsokuType": key})); } +/// Korean Line Break Anywhere (Paragraph panel menu): the menu item is checked when every paragraph +/// the panel acts on ([`format`]) has it, and a click turns it off for all of them then, and on for +/// all of them otherwise, in one undo step. +fn toggle_korean_line_break_anywhere(app: &mut VectorcraftApp) { + let on = shared_para(app, |p| p.korean_line_break_anywhere) == Some(true); + format(app, json!({"koreanLineBreakAnywhere": !on})); +} + /// Reset Panel: the paragraphs the panel acts on ([`format`]) take Auto alignment, no list, no /// indents or paragraph spacing, Hyphenate off with the default Hyphenation and Justification /// options, Auto direction, Bottom-to-Bottom Leading, the Line-end Punctuation Half Width Mojikumi /// Set, Regular burasagari, the Hard kinsoku set, Push Out Only, the Every-line Composer, Roman -/// Hanging Punctuation off, no kashidas and no tab stops, as one undo step named Reset Panel (inside -/// an undo group that is already open, as part of its step). +/// Hanging Punctuation and Korean Line Break Anywhere off, no kashidas and no tab stops, as one undo +/// step named Reset Panel (inside an undo group that is already open, as part of its step). fn reset_panel(app: &mut VectorcraftApp) { let h = HyphenationOptions::default(); let j = vectorcraft_doc::JustificationOptions::default(); @@ -924,7 +937,7 @@ fn reset_panel(app: &mut VectorcraftApp) { "wordSpacing": j.word_spacing, "letterSpacing": j.letter_spacing, "glyphScaling": j.glyph_scaling, "autoLeading": j.auto_leading, "singleWordJustification": "fullJustify", "direction": "auto", "leadingModel": "romanBaseline", "mojikumi": "lineEndHalf", "burasagari": "standard", "kinsoku": "hard", - "kinsokuType": "pushOutOnly", "composer": "everyLine", "romanHangingPunctuation": false + "kinsokuType": "pushOutOnly", "composer": "everyLine", "romanHangingPunctuation": false, "koreanLineBreakAnywhere": false }), ); para_cmd(app, "text.tabs.clear", json!({})); diff --git a/crates/ui-egui/src/panels/paragraph/tests.rs b/crates/ui-egui/src/panels/paragraph/tests.rs index 347975f680..517ef1eeab 100644 --- a/crates/ui-egui/src/panels/paragraph/tests.rs +++ b/crates/ui-egui/src/panels/paragraph/tests.rs @@ -133,6 +133,39 @@ fn kinsoku_shori_type_sets_the_selected_paragraphs_in_one_undo_step() { assert_eq!(types(&app), (KinsokuShoriType::PushOutOnly, KinsokuShoriType::PushOutOnly)); } +/// Korean Line Break Anywhere: new type has it off; the menu item turns it on for the paragraphs +/// the Type tool's selection touches in one undo step, is unchecked while the selected paragraphs +/// differ, turns it on for all of them then and off again once they all have it. Reset Panel turns +/// it off. +#[test] +fn korean_line_break_anywhere_toggles_the_selected_paragraphs_in_one_undo_step() { + let (mut app, id) = editing_text("가나\n다라\n"); + app.session.prefs.show_east_asian_options = true; + let flags = |app: &VectorcraftApp| { + let node = app.session.active().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap(); + let NodeKind::Text(text) = &node.kind else { panic!("text") }; + (text.para_at(0).korean_line_break_anywhere, text.para_at(1).korean_line_break_anywhere) + }; + assert_eq!(flags(&app), (false, false), "new type has it off"); + select_range(&mut app, 0, 3); + let history = app.session.active().unwrap().history.undo.len(); + toggle_korean_line_break_anywhere(&mut app); + assert_eq!(flags(&app), (true, false)); + assert_eq!(app.session.active().unwrap().history.undo.len(), history + 1); + app.session.set_tool_option("selectAll", &json!(true)); + assert_eq!(shared_para(&app, |p| p.korean_line_break_anywhere), None, "mixed: unchecked"); + toggle_korean_line_break_anywhere(&mut app); + assert_eq!(flags(&app), (true, true)); + app.session.set_tool_option("selectAll", &json!(true)); + toggle_korean_line_break_anywhere(&mut app); + assert_eq!(flags(&app), (false, false)); + app.session.set_tool_option("selectAll", &json!(true)); + toggle_korean_line_break_anywhere(&mut app); + app.session.set_tool_option("selectAll", &json!(true)); + reset_panel(&mut app); + assert_eq!(flags(&app), (false, false), "Reset Panel turns it off"); +} + #[test] fn paragraph_mixed_indent_fields_show_blank() { let (mut app, id) = editing_text("A\nB\n"); diff --git a/crates/ui-egui/src/picks.rs b/crates/ui-egui/src/picks.rs index 7699516d78..d2b0a26d3f 100644 --- a/crates/ui-egui/src/picks.rs +++ b/crates/ui-egui/src/picks.rs @@ -125,6 +125,13 @@ pub(crate) struct Picks { replacing: Option, /// The file that question's OK allows to be replaced while what asked runs again. replace_ok: Option, + /// The files that the OK of the question whether to replace the files a save or export writes + /// beside the one picked allows to be replaced while it runs that again + /// ([`crate::dialogs::confirm::ask_replace_files`]). + pub(crate) replace_files_ok: Vec, + /// The files each document's last save wrote beside its own file, by document uid: its next + /// save replaces them without asking. + pub(crate) written_beside: std::collections::HashMap>, /// What runs now handles the answer of a file dialog automation opened ([`as_automated`]). automated: bool, /// The web's file pickers opened ([`open_async`]) that have not answered yet @@ -162,6 +169,7 @@ impl Picks { self.entry = None; self.answer = None; self.replace_ok = None; + self.replace_files_ok.clear(); self.automated = false; } @@ -438,7 +446,7 @@ pub(crate) fn poll(app: &mut VectorcraftApp, ctx: &egui::Context) { /// Make the document an entry asked for (`doc`, by its uid) active again before it runs again: /// another may be active by now (opened from the Finder, by an agent), or it may be gone → false /// when it can't run (the status bar says why). -fn on_document(app: &mut VectorcraftApp, doc: Option) -> bool { +pub(crate) fn on_document(app: &mut VectorcraftApp, doc: Option) -> bool { let Some(uid) = doc else { return true }; let Some(i) = app.session.documents().iter().position(|d| d.uid == uid) else { app.status(tl!("The document was closed while its file dialog was open")); diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index de038296ab..3e74ad394f 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -43,7 +43,8 @@ pub type ReadHeadFn = Box Result, String>>; pub type WriteFn = Box Result<(), String>>; -pub type ExistsFn = Box bool>; +/// Shared, so Background Save and Export can check from a worker thread too. +pub type ExistsFn = Arc bool + Send + Sync>; pub type Inbox = Arc)>>>; @@ -89,8 +90,9 @@ pub struct Services { pub read_head: Option, pub write: Option, /// Is there a file or folder at a path (desktop)? The app asks before replacing a file that a - /// save panel's name names once the app gives it an extension ([`picks::ask_to_replace`]). - /// Without it, no file counts as there. + /// save panel's name names once the app gives it an extension ([`picks::ask_to_replace`]), and + /// before a save or export replaces a file it writes beside that one (a file per artboard, a + /// linked image). Without it, no file counts as there. pub exists: Option, /// Files that arrived asynchronously (web drops): (name, bytes). pub inbox: Option, diff --git a/crates/ui-egui/src/tests/background.rs b/crates/ui-egui/src/tests/background.rs index add53156e8..a71a55d1fe 100644 --- a/crates/ui-egui/src/tests/background.rs +++ b/crates/ui-egui/src/tests/background.rs @@ -122,6 +122,29 @@ fn exports_run_in_the_background_and_the_preference_turns_both_off() { assert!(app.background.jobs.is_empty()); } +/// A Background Export with a file per artboard checks for files there from its worker: one with +/// an artboard file's name is asked about once the export ends, before anything is written, and +/// the question's OK writes every file. +#[test] +fn a_background_export_asks_before_replacing_an_artboard_file() { + let gate = Gate::default(); + let (mut app, files) = app(gate.clone()); + app.services.exists = Some(Arc::new(|p: &str| p == "/out/a-B.png")); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + app.run("artboard.new", json!({"x": 200, "y": 0, "width": 100, "height": 80, "name": "B"})).unwrap(); + let r = io::export(&mut app, Some("png"), Some("/out/a.png".into()), &json!({"useArtboards": true})).unwrap(); + assert_eq!(r["background"].as_bool(), Some(true), "{r}"); + gate.open(); + background::wait_all(&mut app); + assert!(files.lock().unwrap().is_empty(), "nothing is written while it asks"); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.kind.as_str(), d.str("message").as_str()), (crate::dialogs::confirm::REPLACE_FILES, "“a-B.png” already exists. Replace it?")); + crate::dialogs::confirm(&mut app).unwrap(); + background::wait_all(&mut app); + let names: Vec = files.lock().unwrap().iter().map(|(p, _)| p.clone()).collect(); + assert_eq!(names, ["/out/a-Artboard-1.png", "/out/a-B.png"]); +} + /// A save that returns before the file is written still says what it writes and which options it /// does not use, as `app.save` (MCP's `save_file`) replies with it: what the format loses first, as /// the reply of a save written at once. diff --git a/crates/ui-egui/src/tests/nativeoptions.rs b/crates/ui-egui/src/tests/nativeoptions.rs index 5f136d514c..e97443eb36 100644 --- a/crates/ui-egui/src/tests/nativeoptions.rs +++ b/crates/ui-egui/src/tests/nativeoptions.rs @@ -64,6 +64,98 @@ fn save_as_native_asks_for_its_options_and_writes_every_artboard() { assert_eq!(written.borrow().len(), 10); } +/// The paths written so far. +fn names(written: &crate::tests_svg::Written) -> Vec { + written.borrow().iter().map(|(p, _)| p.clone()).collect() +} + +/// Save As with a file per artboard: a file there with an artboard file's name, which the save +/// panel did not ask about, is asked about before anything is written. Cancel writes nothing; OK +/// writes every file, and Save to the document's own file then writes them again without asking. +#[test] +fn save_as_asks_before_replacing_an_artboard_file_that_is_there() { + let (mut app, written) = app("/tmp/doc.vectorcraft"); + three_boards(&mut app); + let there = ["/tmp/doc-B.vectorcraft", "/tmp/doc-D.vectorcraft", "/tmp/doc-E.vectorcraft"]; + app.services.exists = Some(std::sync::Arc::new(move |p: &str| there.contains(&p))); + let pick = |app: &mut crate::VectorcraftApp| { + app.run("file.saveAs", Value::Null).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("separateArtboards".into(), json!(true)); + d.fields.insert("all".into(), json!(true)); + dialogs::confirm(app).unwrap() + }; + let r = pick(&mut app); + assert_eq!(r["pending"], dialogs::confirm::REPLACE_FILES, "{r}"); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.kind.as_str(), d.str("message").as_str()), (dialogs::confirm::REPLACE_FILES, "“doc-B.vectorcraft” already exists. Replace it?")); + assert_eq!(d.fields["files"], json!(["/tmp/doc-B.vectorcraft"])); + assert!(written.borrow().is_empty(), "nothing is written while it asks"); + dialogs::cancel(&mut app); + assert!(written.borrow().is_empty(), "Cancel writes nothing"); + assert!(app.session.active().unwrap().path.is_none()); + pick(&mut app); + dialogs::confirm(&mut app).unwrap(); + assert_eq!(names(&written), ["/tmp/doc.vectorcraft", "/tmp/doc-Artboard-1.vectorcraft", "/tmp/doc-B.vectorcraft", "/tmp/doc-C.vectorcraft"]); + assert_eq!(app.session.active().unwrap().path.as_deref(), Some("/tmp/doc.vectorcraft")); + app.run("file.save", Value::Null).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(written.borrow().len(), 8); + // A new artboard's file is not one the last save wrote: Save asks about it alone. + app.run("artboard.new", json!({"x": 600, "y": 0, "width": 100, "height": 80, "name": "D"})).unwrap(); + let r = app.run("file.save", Value::Null).unwrap(); + assert_eq!(r["pending"], dialogs::confirm::REPLACE_FILES, "{r}"); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["files"], json!(["/tmp/doc-D.vectorcraft"])); + assert_eq!(written.borrow().len(), 8); + // An artboard added while it asks writes a file the question did not name: OK asks about it. + app.run("artboard.new", json!({"x": 800, "y": 0, "width": 100, "height": 80, "name": "E"})).unwrap(); + dialogs::confirm(&mut app).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["files"], json!(["/tmp/doc-E.vectorcraft"])); + assert_eq!(written.borrow().len(), 8); + dialogs::confirm(&mut app).unwrap(); + assert_eq!(written.borrow().len(), 14); + assert!(!app.session.active().unwrap().is_dirty()); +} + +/// A question asked when a Background Save or Export ends waits for a dialog open then to close. +#[test] +fn the_question_waits_for_an_open_dialog() { + let (mut app, _) = app("/tmp/doc.vectorcraft"); + let uid = app.session.active().unwrap().uid; + dialogs::confirm::tell(&mut app, "Open", ""); + dialogs::confirm::ask_replace_files(&mut app, uid, &["/tmp/doc-B.vectorcraft".into()], &[], "file.save", json!({})); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.str("message")), Some("Open".to_string())); + dialogs::cancel(&mut app); + frame(&mut app); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialogs::confirm::REPLACE_FILES)); +} + +/// Export with a file per artboard asks the same way, naming every file that is there; OK writes +/// them. +#[test] +fn export_asks_before_replacing_artboard_files_that_are_there() { + let (mut app, written) = app("/tmp/art.png"); + three_boards(&mut app); + let there = ["/tmp/art-B.png", "/tmp/art-C.png"]; + app.services.exists = Some(std::sync::Arc::new(move |p: &str| there.contains(&p))); + let r = crate::io::export(&mut app, Some("png"), Some("/tmp/art.png".into()), &json!({"useArtboards": true})).unwrap(); + assert_eq!(r["pending"], dialogs::confirm::REPLACE_FILES, "{r}"); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!( + (d.str("message").as_str(), d.str("detail").as_str(), d.str("__command").as_str()), + ("“art-B.png” already exists. Replace it?", "art-B.png\nart-C.png", "file.exportAs") + ); + assert!(written.borrow().is_empty()); + // Another document made active meanwhile: OK exports the one that asked. + let uid = app.session.active().unwrap().uid; + let question = app.ui.dialog.take(); + app.run("file.new", json!({"width": 50, "height": 50})).unwrap(); + app.ui.dialog = question; + dialogs::confirm(&mut app).unwrap(); + assert_eq!(names(&written), ["/tmp/art-Artboard-1.png", "/tmp/art-B.png", "/tmp/art-C.png"]); + assert_eq!(app.session.active().map(|d| d.uid), Some(uid)); +} + #[test] fn save_a_copy_and_save_dont_ask_and_options_reach_the_file() { let (mut app, written) = app("/tmp/plain.vectorcraft"); diff --git a/crates/ui-egui/src/tests/picks.rs b/crates/ui-egui/src/tests/picks.rs index c1e3f822cd..90e6fdc78d 100644 --- a/crates/ui-egui/src/tests/picks.rs +++ b/crates/ui-egui/src/tests/picks.rs @@ -539,7 +539,7 @@ fn preset_dialogs_export_asks_off_the_ui_thread() { #[test] fn a_sheets_answer_given_its_extension_asks_before_replacing() { let (mut app, shown, written) = app(); - app.services.exists = Some(Box::new(|p: &str| p == "/art/card.vectorcraft")); + app.services.exists = Some(std::sync::Arc::new(|p: &str| p == "/art/card.vectorcraft")); app.run("file.new", json!({"width": 100, "height": 80})).unwrap(); assert!(app.run("file.save", json!({})).is_err()); shown.answer(&["/art/card"]); @@ -566,7 +566,7 @@ fn a_preset_dialogs_export_asks_before_replacing_over_it() { w.borrow_mut().push(path.to_string()); Ok(()) })), - exists: Some(Box::new(|_: &str| true)), + exists: Some(std::sync::Arc::new(|_: &str| true)), ..Default::default() }; let mut app = VectorcraftApp::new(Session::new(), services); diff --git a/crates/ui-egui/src/tests/saveext.rs b/crates/ui-egui/src/tests/saveext.rs index 497ac0c749..b8bb2ee6e4 100644 --- a/crates/ui-egui/src/tests/saveext.rs +++ b/crates/ui-egui/src/tests/saveext.rs @@ -215,7 +215,7 @@ fn a_typed_extension_of_a_format_the_save_does_not_write_is_replaced() { #[test] fn a_replaced_extension_asks_before_replacing_a_file_there() { let (mut app, shown, written) = portal_app(); - app.services.exists = Some(Box::new(|p: &str| p == "/art/card.vectorcraft")); + app.services.exists = Some(std::sync::Arc::new(|p: &str| p == "/art/card.vectorcraft")); assert_eq!(saved_from(&mut app, &shown, &written, "file.save", &["/art/card.png"]), "/art/card.vectorcraft"); assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialogs::confirm::REPLACE)); assert!(written.borrow().is_empty()); @@ -254,7 +254,7 @@ fn a_document_in_another_format_saves_as_the_type_picked() { #[test] fn a_name_given_the_picked_types_extension_asks_before_replacing() { let (mut app, shown, written) = portal_app(); - app.services.exists = Some(Box::new(|p: &str| p == "/art/poster.ai")); + app.services.exists = Some(std::sync::Arc::new(|p: &str| p == "/art/poster.ai")); assert_eq!(saved_from(&mut app, &shown, &written, "file.save", &["/art/poster", "ai"]), "/art/poster.ai"); assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialogs::confirm::REPLACE)); assert!(written.borrow().is_empty()); @@ -284,7 +284,7 @@ fn on_disk(tag: &str, typed: &str) -> (VectorcraftApp, std::path::PathBuf) { let picked = dir.join(typed).to_string_lossy().into_owned(); let services = Services { write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))), - exists: Some(Box::new(|p: &str| std::path::Path::new(p).exists())), + exists: Some(std::sync::Arc::new(|p: &str| std::path::Path::new(p).exists())), pick_save: Some(Box::new(move |_: &FilePick| Some(picked.clone()))), ..Default::default() }; @@ -371,7 +371,7 @@ fn every_write_through_a_save_panel_asks_before_replacing() { ]; for (cmd, params, path) in cases { let (mut app, w) = saving_app("/tmp/out"); - app.services.exists = Some(Box::new(|_: &str| true)); + app.services.exists = Some(std::sync::Arc::new(|_: &str| true)); app.run("select.all", json!({})).unwrap(); assert!(app.run(cmd, params).is_err(), "{cmd}"); assert!(w.borrow().is_empty(), "{cmd}: nothing is written while it asks"); @@ -382,7 +382,7 @@ fn every_write_through_a_save_panel_asks_before_replacing() { } // Export As: the question's OK picks the file again, and PNG Options follows. let (mut app, w) = saving_app("/tmp/out"); - app.services.exists = Some(Box::new(|_: &str| true)); + app.services.exists = Some(std::sync::Arc::new(|_: &str| true)); app.run("file.exportAs", json!({"format": "png"})).unwrap(); assert!(dialogs::confirm(&mut app).is_err()); let open = |app: &VectorcraftApp| app.ui.dialog.as_ref().map(|d| (d.kind.clone(), d.str("path"))); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index d5bbd09507..6619cffd6f 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -111,7 +111,7 @@ the two can still win that race. | `app.export` with `useArtboards` | `{path, useArtboards: true, range? \| artboards?, …}` | Export As: PNG, JPEG and WebP write one file per chosen artboard (default all; SVG does so for a `range`), `-.`, and return `{path, files: [path…]}`; a PDF keeps them as pages of `path`. `useArtboards: false` covers the bounds of the visible art. The menu's Export As… is `file.exportAs`: the `exportAs` dialog (`format`, `useArtboards`, `all`, `range`), then a save dialog and the format's options: `pngOptions` / `jpgOptions` / `webpOptions`, `svgOptions`, or `savePdf` for a PDF with Use Artboards | | `ui.dialog.*` on `gradientStop` | `{field: "color" \| "opacity" \| "location", value}` | double-clicking a stop on the Gradient tool's annotator opens its popover (fields `index`, `x`, `y`, `tab`); set `color` (hex), `opacity` or `location` (percentages) and `ui.dialog.confirm` to apply them to the selected stop. `gradient.selectStop {index}` picks the stop the annotator, the panels and Delete/←/→ (`ui.key`) act on | | `engine.execute` `ui.savePdfDialog` | `{path?, preset?, range?, …document.exportPdf options}` | opens the Save PDF dialog (`file.export.pdf` with no params opens it too). Its fields are the `document.exportPdf` options: set `compatibility` with `ui.dialog.set {field: "compatibility", value: "1.5"}`, a whole section with `{field: "compression", value: {…}}`, the artboards with `__allArtboards: false` and `range`, the visible section with `__section` (General, Compression, Marks and Bleeds, Output, Advanced, Security, Summary). `ui.dialog.confirm` writes `path` (else asks for one), returns `{path, bytes, warnings}` and opens the file when `viewAfterSaving` is set | -| `file.save` / `file.saveAs` / `file.saveCopy` / `file.saveAsTemplate` (via `engine.execute`) | `{path?, format?, options?, svg?}` | Save writes the document's own file in its own format (a document opened from or saved as SVG/PDF saves as that again; the result's `warnings` say what the format loses). With `path` the file is written at once (Save As or a Copy to an SVG path without SVG options first opens SVG Options, below). Without one (never saved, a converted older file, Save As, a Copy, a Template) the app shows a save panel with one file type per save format (`vectorcraft`, `template`, `pdf`, `svg`, `svgz`, `ai`; for a Template, `template` and `ait`), then the format's options: SVG opens the `svgOptions` dialog (below; returns `{"pending": "svgOptions", "path"}`), PDF the `savePdf` dialog (its `__save` field names the save command); `ui.dialog.confirm` finishes the save. A name typed in the panel without the file type's extension gets it, and a name the panel returns with the extension of a format the panel does not list has it replaced (in a native save, `card.png` and `card.ait` are saved as `card.vectorcraft`, and `notes.v2`, whose extension names no format, is saved as `notes.v2.vectorcraft`); when a file of that name exists, which the panel did not ask about, the save (and every export or other write through a save panel) answers `cancelled` and opens a `replaceFile` dialog (fields `message`, `detail`, `path`): `ui.dialog.confirm` writes that file, `ui.dialog.cancel` writes nothing. On the web a `saveOptions` dialog (fields `path`, `format`) first names the file and picks the format. `file.formatOptions {format?}` reads the options a save would use. `file.revert` opens a `confirm` dialog (`{"pending": "confirm"}`); `ui.dialog.confirm` reloads the saved file in the same tab | +| `file.save` / `file.saveAs` / `file.saveCopy` / `file.saveAsTemplate` (via `engine.execute`) | `{path?, format?, options?, svg?}` | Save writes the document's own file in its own format (a document opened from or saved as SVG/PDF saves as that again; the result's `warnings` say what the format loses). With `path` the file is written at once (Save As or a Copy to an SVG path without SVG options first opens SVG Options, below). Without one (never saved, a converted older file, Save As, a Copy, a Template) the app shows a save panel with one file type per save format (`vectorcraft`, `template`, `pdf`, `svg`, `svgz`, `ai`; for a Template, `template` and `ait`), then the format's options: SVG opens the `svgOptions` dialog (below; returns `{"pending": "svgOptions", "path"}`), PDF the `savePdf` dialog (its `__save` field names the save command); `ui.dialog.confirm` finishes the save. A name typed in the panel without the file type's extension gets it, and a name the panel returns with the extension of a format the panel does not list has it replaced (in a native save, `card.png` and `card.ait` are saved as `card.vectorcraft`, and `notes.v2`, whose extension names no format, is saved as `notes.v2.vectorcraft`); when a file of that name exists, which the panel did not ask about, the save (and every export or other write through a save panel) answers `cancelled` and opens a `replaceFile` dialog (fields `message`, `detail`, `path`): `ui.dialog.confirm` writes that file, `ui.dialog.cancel` writes nothing. A save or export that writes other files beside that path (a file per artboard, linked images, image maps) checks them before it writes anything: when any of them exists, it writes nothing and opens a `replaceFiles` dialog (fields `message`, `detail` (each file's name when there are several), `files`, `__command`, `__params`; the reply is `{"pending": "replaceFiles"}`, or comes once a Background Save or Export ends): `ui.dialog.confirm` runs the save or export again and replaces them, `ui.dialog.cancel` writes nothing; a dialog open when a background job asks stays open, and the question follows it. A save does not ask about the files the document's last Save or Save As in this session wrote. On the web a `saveOptions` dialog (fields `path`, `format`) first names the file and picks the format. `file.formatOptions {format?}` reads the options a save would use. `file.revert` opens a `confirm` dialog (`{"pending": "confirm"}`); `ui.dialog.confirm` reloads the saved file in the same tab | | SVG Options (`engine.execute` of `file.export.svg`, `file.saveAs`, `file.saveCopy`) | `{}` / `{path?, svg?: {…}}` | `file.export.svg` without params, and Save As or Save a Copy to an `.svg`/`.svgz` path without SVG options, open the `svgOptions` dialog (`ui.dialog.set` its fields: `styling`, `outlineText`, `images`, `objectIds`, `decimals`, `minify`, `responsive`, `fewerTspans`, `metadata`, `preserveEditing`, `useArtboards`, `allArtboards`, `range`, `showCode`, `profile` (`svg11`/`tiny12`), `encoding` (`utf8`/`utf16`/`latin1`), `embedFonts`; `ui.dialog.confirm` exports or saves and remembers the choices). With `svg: {…}` (the `document.formats` SVG options) they run directly. `app.save {path?, svg?}` takes the same options; a document saved as SVG saves as SVG again with the same options | | `engine.execute` `file.place` | `{paths?}` / `{path \| name+dataBase64, …file.place params}` | with no file, picks files and opens the `place` dialog; with `paths`, opens it for them (fields `files`, `link`, `template`, `replace`; `__replace` says whether Replace applies, `__info` describes each file). `ui.dialog.confirm` places one file centered in the view (or in place of the selected object) and loads the place cursor with several (`file.place.queue`). In the desktop app the dialog reads its files off the UI thread: `__info` is empty until they are read, `ui.dialog.confirm` returns `{background: true, status}`, `ui.inspect` lists the files still being read in `placing`, and the file is placed (or the cursor loaded) once it is read. With a file, it runs `file.place` centered in the view, at once. Files dropped on the window with no document open open. On the web, files dropped on the canvas are placed at the drop point (linked; Shift embeds) and files dropped off it (the tab bar) open, as in Illustrator; desktop drops carry no pointer position, so documents (native, SVG, PDF, `.ai`, EPS, DXF, EMF/WMF) open as tabs of their own and pictures and text are placed in the middle of the view. Swatch libraries and color books (`.vcswatches`, `.gpl`, `.ase`, `.acb`), graphic style libraries (`.vcstyles`), Libraries panel files (`.vclibrary`), presets (`.vcflattener`, `.vcpdfpresets`, `.vcprintpresets`, `.vcperspective`, `.vcexportpresets`), plug-ins (`.wasm`) and shortcut sets (`.kys`, in Import Shortcut Set) open wherever they are dropped, as File › Open opens them. With the cursor loaded (`ui.inspect` → `tool: "place"`, `toolOptions: {count, current, name, names}`), `ui.pointer` clicks and drags place files (in the desktop app each file is read off the UI thread and placed once it is read, and `ui.inspect` lists it in `placing` until then) and `ui.key` ←/→/↑/↓ and Escape cycle and discard them | | `app.export` raster options | `{path?, format: png \| jpg \| webp, ppi?, width?, height?, background?, antiAlias?, interlaced?, quality?}` | `ppi` sets the pixel size (72 = one pixel per point) and is stored in PNG and JPEG files (WebP files store no resolution); `width` or `height` sets the size of each file in pixels instead (`width` wins), and PNG and JPEG files store the resolution that results; `background` is `transparent`, `white`, `black` or `"#rrggbb"`; `antiAlias` is `none`, `art` or `type` (text snapped to pixels); `interlaced` writes an Adam7 PNG. The PNG Options dialog (`pngOptions`; `jpgOptions` / `webpOptions` for the other raster formats) has the same fields: fill them with `ui.dialog.set` and press OK with `ui.dialog.confirm` | diff --git a/docs/mcp.md b/docs/mcp.md index c65f184bdd..4ac7a10930 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -463,7 +463,14 @@ or a new object (`css.export`, `object.isolate`, `layer.locate`, `layer.releaseT counterparts, `image.info`, `links.info`, `links.goTo`, `links.unembed`, `shaper.release`, `shaper.expand`, `transparency.editOpacityMask`, `transparency.viewOpacityMask`, `symbol.edit`, the `object.blend.spine.*` commands and `path.curvature`) refuse an `id` that is not a non-negative integer (`"abc"`, `"5"`, `true`) with an error and -change nothing; an omitted or null `id` keeps the default. A command that leaves out an object selected inside a +change nothing; an omitted or null `id` keeps the default. Commands that need an object id (`document.node`, the +`path.*` anchor and handle commands, `text.reshapePath`, `text.editRange`, the `object.mesh.*` point commands, +`layer.target`, `node.move`, `select.magicWand` and the others like them, and the `source`, `target`, `group` and +`path` keys of `appearance.transfer`, `appearance.copyFrom`, `layer.move`, the `livePaint.*` commands and +`text.createInPath`) refuse a value that is not a non-negative integer with "`id` must be a whole number", naming the +key, and report a key left out or null as missing. The optional `group` of `livePaint.fill` and `parent` of +`node.move` are refused the same way when given; left out, they keep their defaults. `livePaint.info` reads `id` when +`group` is left out or null, and reports a missing group only when both are left out. A command that leaves out an object selected inside a selected group also leaves it out when `ids` list it together with a group that holds it. Every command that takes `ids` (or `slices`, for the slice commands) refuses a list with an entry that is not a non-negative integer, or a value that is not a list, and changes nothing; it does not act on the other entries. The error for an entry names the key, @@ -6532,9 +6539,15 @@ else its topmost visible fill's, where a fill or stroke at 0% opacity does not c of its stops, a gradient mesh's the average of its points' colors (points at 0% opacity left out), and a pattern's its first visible solid fill (gray when it has none). The pens draw in turn, each pen's paths in paint order, or with `sortPaths: true` each next path the one that starts nearest where the pen stopped (an open path may be drawn from its other end, and a closed one -starts at its point nearest the pen). Curves become straight moves no further than `curveTolerance` points from the -curve (default 0.1, from 0.01 to 10). These defaults, the 255-pen limit and the rules for pen numbers and colors above -are VectorCraft's own. +starts at its point nearest the pen). With `insideFirst: true`, a path is drawn only after every path of the same +pen that lies inside it, for example a letter's counter before the letter and a part's holes before the part. A +path lies inside a closed path when it does not cross that path's line, every part of it lies inside that path (by +the even-odd rule) or on its line, and some part lies inside: a path touching the line around it is inside, a path +crossing it or leaving it through a corner is not, and copies of one outline are not inside each other. Without +`sortPaths` the paths otherwise keep paint order; with it, the next path is the nearest of those free to draw. +Curves become straight moves no further than `curveTolerance` points from the curve (default 0.1, from 0.01 to 10). +These defaults, the 255-pen limit and the rules for pen numbers, colors and the order of paths above are +VectorCraft's own. `hpglVersion: "hpgl"` (the default) writes `IN;`, then for each pen `PU;SP;` and each path `PUx,y;PDx,y,…;`, and ends with `PU;SP0;`. `"hpgl2"` also writes the palette (`NP` and a `PC` color for each pen) and draws each pen's @@ -6543,11 +6556,16 @@ with a coordinate outside −32,768 to 32,767 units (about 82 cm from the origin as it is, with a warning that older devices may not read it; this check is VectorCraft's own. `warnings` names type drawn as outlines, inline graphics in type (left out), placed images, opacity masks, raster effects, stroke looks that are not drawn, art past the plotter's range, HP-GL coordinates past 16-bit numbers, colors past 255 pens, -and the limits on points, clipping work, symbols, live art, nesting depth and sorting. +and the limits on points, clipping work, symbols, live art, nesting depth and sorting. Sorting and finding which paths lie inside which share one +work budget for the plot, 500 million box and point comparisons. When sorting runs out of it, the paths not yet +drawn keep paint order, each after the paths inside it with `insideFirst`. When it runs out while the writer is +finding which paths lie inside which, the paths of that pen and of the later pens keep paint order. Both cases add +a warning. ```json {"name":"export","arguments":{"path":"/tmp/decal.plt","format":"hpgl"}} {"name":"run_command","arguments":{"command":"document.export","params":{"format":"hpgl","path":"/tmp/sheet.plt","useArtboards":true,"hpglVersion":"hpgl2","pens":["#000000","#ff0000"],"sortPaths":true}}} +{"name":"run_command","arguments":{"command":"document.export","params":{"format":"hpgl","path":"/tmp/stencil.plt","sortPaths":true,"insideFirst":true}}} ``` ## TIFF, BMP and Targa export @@ -7348,6 +7366,25 @@ Panel sets it back to Push Out Only. One undo step. {"name":"run_command","arguments":{"command":"text.setFormat","params":{"kinsokuType":"pushInFirst"}}} ``` +## Korean Line Break Anywhere + +`text.setFormat {koreanLineBreakAnywhere: bool}` (Paragraph panel menu › Korean Line Break Anywhere, shown with the +East Asian options) turns it on or off for the paragraphs of the selected type (or `ids`, or the paragraphs a `start` / +`end` range touches). Off, a Korean word (the characters between two spaces, when any of them is Hangul, Han and kana +included) breaks only at its spaces, after East Asian punctuation in it, and at forced line breaks, so a word that does +not fit moves to the next line whole; a word wider than the line still breaks where it overflows. On, a line may break +between any two Hangul syllables. Both composers and vertical type follow it, No Break and the Kinsoku Set still apply, +and hyphenation never adds a hyphen to Hangul. Text without Hangul keeps its own breaks. New type and imported text have +it off; paragraphs saved before the option open with it on, the breaks they were laid out with. It is saved with the +paragraph (`korean_line_break_anywhere` in `para`) only when off, paragraph styles carry it (Paragraph Style Options › +East Asian Composition Settings), and Reset Panel turns it off. A value that is not true or false is refused. One undo +step. `.ai` editing data does not hold it, and a save reports a Korean paragraph with it on as left out. The +help page: https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/set-line-breaks-korean-text.html + +```json +{"name":"run_command","arguments":{"command":"text.setFormat","params":{"koreanLineBreakAnywhere":true}}} +``` + ## Hanging punctuation (burasagari) `text.setFormat {burasagari: "none"|"standard"|"forced"}` sets the Paragraph panel menu's Burasagari (None / Regular / @@ -8056,8 +8093,8 @@ the Paragraph Styles panel, or Paragraph Style Options… in its menu, does the `paraStyle.edit {name, to, attrs}`, which takes the same arguments as `charStyle.edit` and replaces the style's name and attributes in one undo step; the paragraphs that use the style keep their local overrides. OK with no field changed records no undo step, and Preview updates the paragraphs while the dialog is open (Cancel restores them). -The `paraStyleOptions` dialog exposes `name`, `section` (`general`, `basic`, `tabs`, `composition`, `hyphenation` or -`justification`) and `preview`, and these fields: +The `paraStyleOptions` dialog exposes `name`, `section` (`general`, `basic`, `tabs`, `composition`, `hyphenation`, +`justification` or `eastAsian`) and `preview`, and these fields: - Basic Paragraph Formats: `justify` (`Auto`, `Left`, `Center`, `Right`, `JustifyLeft`, `JustifyCenter`, `JustifyRight` or `JustifyAll`), and `left_indent`, `right_indent`, `first_line_indent`, `space_before` and @@ -8076,6 +8113,8 @@ The `paraStyleOptions` dialog exposes `name`, `section` (`general`, `basic`, `ta - Justification: `word_min`, `word_desired`, `word_max`, `letter_min`, `letter_desired`, `letter_max`, `glyph_min`, `glyph_desired` and `glyph_max` (percent), `auto_leading` (percent) and `single_word` (`fullJustify`, `alignLeft`, `alignCenter` or `alignRight`). +- East Asian Composition Settings: the `korean_line_break_anywhere` checkbox (Korean Composition Settings › Korean + Line Break Anywhere). A field left as it was keeps the attribute as the style has it, and an empty field leaves its attribute unset; an empty Hyphenation or Justification field takes its default while another field of that section is set. Attributes @@ -8090,8 +8129,9 @@ position. A value the style already has is kept. An invalid value leaves the style, its paragraphs and Undo history unchanged, and keeps the dialog open. Reset Panel removes the section's attributes from the definition: Basic Paragraph Formats removes `justify` and the five distances, Tabs removes `tabs`, Composition removes `composer` and `roman_hanging`, Hyphenation removes `hyphenate` and -`hyphenation`, and -Justification removes `justification`. `ui.paraStyleOptions.resetPanel {}` performs that reset for the section +`hyphenation`, +Justification removes `justification`, and East Asian Composition Settings removes `korean_line_break_anywhere`. +`ui.paraStyleOptions.resetPanel {}` performs that reset for the section selected in Paragraph Style Options or New Paragraph Style; it is disabled on General. In the native file a text object's `para` holds the first paragraph's attributes and `paras` (left out when every diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 69e868a581..3cc8df2a9e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1202,7 +1202,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Type › Optical Margin Alignment (`type.opticalMarginAlignment`) hangs the punctuation and letter edges at the ends of a type object's lines outside its margins: at the left of left-aligned lines, at the right of right-aligned lines, and at both ends of centered and justified lines, in point and area type of left-to-right paragraphs. Quotation marks, hyphens, full stops and commas hang wholly; asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half; and A, V, W, Y, v, w and y hang by a tenth of their width. It is off by default and saved with the type object. Justification's Glyph Scaling leaves the glyphs that hang at their width, so they hang exactly their share, and Standard burasagari counts what hangs at a line's start as room before hanging a comma; `crates/text/src/tests/hang.rs` checks whole paragraphs with both composers, ragged, centred and justified, with Line-end Punctuation Half Width and bidirectional lines. Optical Margin Alignment and Roman Hanging Punctuation hang right-to-left paragraphs of point and area type, and type on a path whose first paragraph runs right to left, too. Auto aligns a right-to-left line at its right, or at the end bracket of a path, so it hangs there. A right-to-left line is read from right to left: where it hangs at its left margin, `."` ending it hangs both glyphs. The Paragraph panel menu's Kinsoku Shori Type (`text.setFormat {kinsokuType}`, with the East Asian options) picks Push In First, Push Out First or Push Out Only for each paragraph: a character the Kinsoku Set keeps off a line's start or end is set on the line, with the line's gaps next to CJK characters closed by at most a tenth of an em each to fit, or moved to the next line with the character before it. Push Out First pushes in only when pushing out would leave the line more than an em short. Push Out Only is the default, the setting is saved only when it is not, and paragraph styles carry it. - The Paragraph panel menu's Reset Panel (`text.setStyle`, `text.setFormat` and `text.tabs.clear`) gives the selected type's paragraphs, or the paragraphs the Type tool's selection touches, Auto alignment, no indents or paragraph spacing, Hyphenate off with the default Hyphenation and Justification options, Auto direction, Bottom-to-Bottom Leading, the Line-end Punctuation Half Width Mojikumi Set, Regular burasagari, the Hard kinsoku set, Push Out Only, the Every-line Composer, Roman Hanging Punctuation off, no kashidas and no tab stops, as one undo step. + The Paragraph panel menu's Korean Line Break Anywhere (`text.setFormat {koreanLineBreakAnywhere}`, with the East Asian options, and the East Asian Composition Settings section of Paragraph Style Options) sets for each paragraph whether a line may break between any two Hangul syllables. When it is off, a word that contains Hangul breaks only at its spaces and after punctuation, or where the word is wider than the line, and takes no hyphen. New type has it off; paragraphs saved before the option read as on, so their lines do not move. + The Paragraph panel menu's Reset Panel (`text.setStyle`, `text.setFormat` and `text.tabs.clear`) gives the selected type's paragraphs, or the paragraphs the Type tool's selection touches, Auto alignment, no indents or paragraph spacing, Hyphenate off with the default Hyphenation and Justification options, Auto direction, Bottom-to-Bottom Leading, the Line-end Punctuation Half Width Mojikumi Set, Regular burasagari, the Hard kinsoku set, Push Out Only, the Every-line Composer, Roman Hanging Punctuation and Korean Line Break Anywhere off, no kashidas and no tab stops, as one undo step. The Paragraph panel menu's Hyphenation… (`ui.hyphenation`, `text.setFormat {wordsLongerThan, afterFirst, beforeLast, hyphenLimit, hyphenationZone, hyphenateCapitalized}`) sets each paragraph's hyphenation limits: the shortest word that breaks, the fewest letters on each side of the hyphen, the most hyphenated lines in a row, a zone at the right edge where the Single-line Composer breaks before a word instead of hyphenating it, and whether capitalized words break. The defaults are words longer than 5 letters, 3 letters on each side, no limit or zone, and capitalized words hyphenated; paragraph styles and the native file keep the options. PDF export with type as text writes the hyphen that ends a line broken inside a word as a hyphen character where the line draws it, and the file opens with the hyphen in the line's text; kashidas are drawn as paths. The Paragraph panel's Insert Kashidas (with the Indic options; `text.setFormat {kashida}`) lengthens each Arabic word of a justified horizontal area type line with a kashida, a tatweel between its last pair of joined letters, before the word spaces widen: Short, Medium and Long kashidas are at most a quarter, half and a whole em long, Stylistic ones are whole tatweels up to an em, and None (the default) leaves the line as before; paragraph styles and the native file keep the setting. @@ -1520,6 +1521,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - 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`). - Export As › HP-GL (`document.export {format: "hpgl"}`) writes a `.plt` plot of an artboard, each artboard or the art for pen plotters and cutters: each visible path once along its line in plotter units from the artboard's bottom-left corner or center, a pen per color or a pen table, curves flattened to a tolerance, type as outlines, clipping masks and the artboard's edges applied, and HP-GL or HP-GL/2 (pen colors and encoded polylines); plots read back by the HP-GL reader give the same lines (`crates/cad/src/hpgl/write/tests.rs`, `crates/engine/src/cmd/fileio/tests_hpgl.rs`). + - HP-GL export's `insideFirst` cuts each pen's paths inside before outside: a path is drawn only after the pen's paths that lie inside it (a letter's counter, a part's holes, rings within rings), touching outlines counted as inside and crossing ones not, in paint order or nearest first with `sortPaths`, within the plot's work budget; these order rules are VectorCraft's own (`crates/cad/src/hpgl/write/nest.rs`, `crates/cad/src/hpgl/write/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`).